xxf_react 0.5.3 → 0.5.4
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 +4 -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/dist/media/playback-queue-store.d.ts +642 -0
- package/dist/media/playback-queue-store.d.ts.map +1 -0
- package/dist/media/playback-queue-store.js +614 -0
- package/package.json +2 -1
package/README.md
CHANGED
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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/media/index.ts"],"names":[],"mappings":"AAEA,cAAc,eAAe,CAAC;AAC9B,cAAc,wBAAwB,CAAC"}
|
package/dist/media/index.js
CHANGED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview 播放队列状态管理 (Zustand)
|
|
3
|
+
*
|
|
4
|
+
* 支持多 Tab / 多页面场景,全局只有一个视频在播放。
|
|
5
|
+
*
|
|
6
|
+
* @module playback-queue-store
|
|
7
|
+
* @see {@link ./playback-queue-store.md} 详细文档
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Source 数据结构
|
|
11
|
+
*
|
|
12
|
+
* 每个 source 代表一个独立的播放列表,通常对应一个 Tab 或页面。
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const homeSource: SourceData = {
|
|
17
|
+
* itemIds: ['video-1', 'video-2', 'video-3']
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
interface SourceData {
|
|
22
|
+
/**
|
|
23
|
+
* 该 source 下的所有项目 ID 列表
|
|
24
|
+
*
|
|
25
|
+
* - 有序数组,决定播放顺序
|
|
26
|
+
* - ID 应该是唯一的字符串标识符
|
|
27
|
+
*/
|
|
28
|
+
itemIds: string[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 播放队列状态
|
|
32
|
+
*
|
|
33
|
+
* 核心状态结构,包含所有 source 的数据和当前播放状态。
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const state: PlaybackQueueState = {
|
|
38
|
+
* sources: {
|
|
39
|
+
* 'home': { itemIds: ['a', 'b', 'c'] },
|
|
40
|
+
* 'community': { itemIds: ['d', 'e'] },
|
|
41
|
+
* },
|
|
42
|
+
* itemIdToSource: { 'a': 'home', 'b': 'home', 'd': 'community' },
|
|
43
|
+
* activeSource: 'home',
|
|
44
|
+
* currentId: 'a',
|
|
45
|
+
* muted: true,
|
|
46
|
+
* loop: true,
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
interface PlaybackQueueState {
|
|
51
|
+
/**
|
|
52
|
+
* 多个 source 的数据
|
|
53
|
+
*
|
|
54
|
+
* key: source 名称(如 'home', 'community', 'detail-123')
|
|
55
|
+
* value: 该 source 的数据
|
|
56
|
+
*/
|
|
57
|
+
sources: Record<string, SourceData>;
|
|
58
|
+
/**
|
|
59
|
+
* ID 到 source 的反向索引
|
|
60
|
+
*
|
|
61
|
+
* 用于 `playById` 快速查找某个 ID 属于哪个 source。
|
|
62
|
+
* 复杂度从 O(n*m) 降低到 O(1)。
|
|
63
|
+
*
|
|
64
|
+
* @internal 内部使用,自动维护
|
|
65
|
+
*/
|
|
66
|
+
itemIdToSource: Record<string, string>;
|
|
67
|
+
/**
|
|
68
|
+
* 当前激活的 source 名称
|
|
69
|
+
*
|
|
70
|
+
* - 播放、next、prev 操作都在此 source 内进行
|
|
71
|
+
* - 为 null 表示没有激活的 source
|
|
72
|
+
*/
|
|
73
|
+
activeSource: string | null;
|
|
74
|
+
/**
|
|
75
|
+
* 当前正在播放的项目 ID
|
|
76
|
+
*
|
|
77
|
+
* - 全局唯一,保证同一时间只有一个视频在播放
|
|
78
|
+
* - 为 null 表示没有视频在播放
|
|
79
|
+
*/
|
|
80
|
+
currentId: string | null;
|
|
81
|
+
/**
|
|
82
|
+
* 是否静音
|
|
83
|
+
*
|
|
84
|
+
* @default true(默认静音,符合浏览器自动播放策略)
|
|
85
|
+
*/
|
|
86
|
+
muted: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* 是否循环播放
|
|
89
|
+
*
|
|
90
|
+
* - true: 播放到最后一个后自动回到第一个
|
|
91
|
+
* - false: 播放到最后一个后停止
|
|
92
|
+
*
|
|
93
|
+
* @default true
|
|
94
|
+
*/
|
|
95
|
+
loop: boolean;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 播放队列操作方法接口
|
|
99
|
+
*
|
|
100
|
+
* 定义所有可用的操作方法。
|
|
101
|
+
*/
|
|
102
|
+
interface PlaybackQueueActions {
|
|
103
|
+
/**
|
|
104
|
+
* 设置某个 source 的播放列表
|
|
105
|
+
*
|
|
106
|
+
* 用于初始化或更新某个 Tab/页面的播放列表。
|
|
107
|
+
*
|
|
108
|
+
* @param source - source 名称,建议使用有意义的标识符
|
|
109
|
+
* - 'home' - 首页
|
|
110
|
+
* - 'community' - 社区页
|
|
111
|
+
* - 'detail-{id}' - 详情页
|
|
112
|
+
* @param ids - 项目 ID 列表,会自动去重
|
|
113
|
+
* @param autoPlay - 是否自动播放第一个,默认 false
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```tsx
|
|
117
|
+
* // 初始化首页列表
|
|
118
|
+
* const { setSource } = usePlaybackActions()
|
|
119
|
+
*
|
|
120
|
+
* useEffect(() => {
|
|
121
|
+
* setSource('home', templates.map(t => t.id))
|
|
122
|
+
* }, [templates])
|
|
123
|
+
*
|
|
124
|
+
* // 初始化并自动播放
|
|
125
|
+
* setSource('home', ['id-1', 'id-2'], true)
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
setSource: (source: string, ids: string[], autoPlay?: boolean) => void;
|
|
129
|
+
/**
|
|
130
|
+
* 向某个 source 追加项目
|
|
131
|
+
*
|
|
132
|
+
* 用于滚动加载场景,追加新数据时不影响当前播放状态。
|
|
133
|
+
*
|
|
134
|
+
* @param source - source 名称
|
|
135
|
+
* @param ids - 要追加的项目 ID 列表,会自动去重
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```tsx
|
|
139
|
+
* // 滚动加载更多
|
|
140
|
+
* const { appendToSource } = usePlaybackActions()
|
|
141
|
+
*
|
|
142
|
+
* const onLoadMore = (newItems: Template[]) => {
|
|
143
|
+
* appendToSource('home', newItems.map(t => t.id))
|
|
144
|
+
* }
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
appendToSource: (source: string, ids: string[]) => void;
|
|
148
|
+
/**
|
|
149
|
+
* 移除某个 source
|
|
150
|
+
*
|
|
151
|
+
* 用于页面卸载时清理数据,防止内存泄漏。
|
|
152
|
+
* 如果移除的是当前激活的 source,会自动停止播放。
|
|
153
|
+
*
|
|
154
|
+
* @param source - 要移除的 source 名称
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```tsx
|
|
158
|
+
* // 详情页卸载时清理
|
|
159
|
+
* useEffect(() => {
|
|
160
|
+
* setSource(`detail-${id}`, items)
|
|
161
|
+
* return () => removeSource(`detail-${id}`)
|
|
162
|
+
* }, [id])
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
removeSource: (source: string) => void;
|
|
166
|
+
/**
|
|
167
|
+
* 播放指定 source 中的指定项目
|
|
168
|
+
*
|
|
169
|
+
* 会自动切换 activeSource 到指定的 source。
|
|
170
|
+
*
|
|
171
|
+
* @param source - source 名称
|
|
172
|
+
* @param id - 项目 ID
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```tsx
|
|
176
|
+
* const { play } = usePlaybackActions()
|
|
177
|
+
*
|
|
178
|
+
* // 点击卡片播放
|
|
179
|
+
* <div onClick={() => play('home', item.id)}>
|
|
180
|
+
* ...
|
|
181
|
+
* </div>
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
play: (source: string, id: string) => void;
|
|
185
|
+
/**
|
|
186
|
+
* 播放指定项目(自动查找 source)
|
|
187
|
+
*
|
|
188
|
+
* 优先在当前 activeSource 中查找,找不到则通过反向索引查找。
|
|
189
|
+
* 适用于不关心具体 source 的场景。
|
|
190
|
+
*
|
|
191
|
+
* @param id - 项目 ID
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```tsx
|
|
195
|
+
* const { playById } = usePlaybackActions()
|
|
196
|
+
*
|
|
197
|
+
* // 从搜索结果播放(不知道属于哪个 source)
|
|
198
|
+
* <div onClick={() => playById(searchResult.id)}>
|
|
199
|
+
* ...
|
|
200
|
+
* </div>
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
playById: (id: string) => void;
|
|
204
|
+
/**
|
|
205
|
+
* 播放下一个
|
|
206
|
+
*
|
|
207
|
+
* 在当前 activeSource 内播放下一个项目。
|
|
208
|
+
* 如果已是最后一个:
|
|
209
|
+
* - loop=true: 回到第一个
|
|
210
|
+
* - loop=false: 停止播放(currentId 变为 null)
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```tsx
|
|
214
|
+
* const { next } = usePlaybackActions()
|
|
215
|
+
*
|
|
216
|
+
* <button onClick={next}>下一个</button>
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
next: () => void;
|
|
220
|
+
/**
|
|
221
|
+
* 播放上一个
|
|
222
|
+
*
|
|
223
|
+
* 在当前 activeSource 内播放上一个项目。
|
|
224
|
+
* 如果已是第一个:
|
|
225
|
+
* - loop=true: 跳到最后一个
|
|
226
|
+
* - loop=false: 停止播放
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```tsx
|
|
230
|
+
* const { prev } = usePlaybackActions()
|
|
231
|
+
*
|
|
232
|
+
* <button onClick={prev}>上一个</button>
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
prev: () => void;
|
|
236
|
+
/**
|
|
237
|
+
* 停止播放
|
|
238
|
+
*
|
|
239
|
+
* 将 currentId 设为 null,但保留 activeSource。
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```tsx
|
|
243
|
+
* const { stop } = usePlaybackActions()
|
|
244
|
+
*
|
|
245
|
+
* <button onClick={stop}>停止</button>
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
stop: () => void;
|
|
249
|
+
/**
|
|
250
|
+
* 播放完成回调
|
|
251
|
+
*
|
|
252
|
+
* 应在视频的 onEnded 事件中调用,会自动播放下一个。
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* ```tsx
|
|
256
|
+
* const { onEnded } = usePlaybackActions()
|
|
257
|
+
*
|
|
258
|
+
* <video onEnded={onEnded} />
|
|
259
|
+
* ```
|
|
260
|
+
*/
|
|
261
|
+
onEnded: () => void;
|
|
262
|
+
/**
|
|
263
|
+
* 设置静音状态
|
|
264
|
+
*
|
|
265
|
+
* @param muted - true 静音,false 取消静音
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* ```tsx
|
|
269
|
+
* const { setMuted } = usePlaybackActions()
|
|
270
|
+
*
|
|
271
|
+
* setMuted(true) // 静音
|
|
272
|
+
* setMuted(false) // 取消静音
|
|
273
|
+
* ```
|
|
274
|
+
*/
|
|
275
|
+
setMuted: (muted: boolean) => void;
|
|
276
|
+
/**
|
|
277
|
+
* 切换静音状态
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```tsx
|
|
281
|
+
* const { toggleMute } = usePlaybackActions()
|
|
282
|
+
* const muted = usePlaybackMuted()
|
|
283
|
+
*
|
|
284
|
+
* <button onClick={toggleMute}>
|
|
285
|
+
* {muted ? '🔇' : '🔊'}
|
|
286
|
+
* </button>
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
toggleMute: () => void;
|
|
290
|
+
/**
|
|
291
|
+
* 设置循环播放
|
|
292
|
+
*
|
|
293
|
+
* @param loop - true 循环播放,false 播完停止
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```tsx
|
|
297
|
+
* const { setLoop } = usePlaybackActions()
|
|
298
|
+
*
|
|
299
|
+
* setLoop(false) // 关闭循环
|
|
300
|
+
* ```
|
|
301
|
+
*/
|
|
302
|
+
setLoop: (loop: boolean) => void;
|
|
303
|
+
/**
|
|
304
|
+
* 重置所有状态
|
|
305
|
+
*
|
|
306
|
+
* 将状态恢复到初始值,清空所有 source 和播放状态。
|
|
307
|
+
* 通常用于用户登出或需要完全重置的场景。
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```tsx
|
|
311
|
+
* const { reset } = usePlaybackActions()
|
|
312
|
+
*
|
|
313
|
+
* // 用户登出时清理
|
|
314
|
+
* const handleLogout = () => {
|
|
315
|
+
* reset()
|
|
316
|
+
* // ...其他清理逻辑
|
|
317
|
+
* }
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
reset: () => void;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* 播放队列 Store 完整类型
|
|
324
|
+
*
|
|
325
|
+
* 包含状态和操作方法。
|
|
326
|
+
*/
|
|
327
|
+
type PlaybackQueueStore = PlaybackQueueState & PlaybackQueueActions;
|
|
328
|
+
/**
|
|
329
|
+
* 播放队列 Zustand Store
|
|
330
|
+
*
|
|
331
|
+
* 使用 `subscribeWithSelector` 中间件支持选择性订阅。
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* ```tsx
|
|
335
|
+
* // 直接使用 store(不推荐,建议使用封装的 hooks)
|
|
336
|
+
* const currentId = usePlaybackQueueStore(state => state.currentId)
|
|
337
|
+
* ```
|
|
338
|
+
*/
|
|
339
|
+
export declare const usePlaybackQueueStore: import("zustand").UseBoundStore<Omit<import("zustand").StoreApi<PlaybackQueueStore>, "subscribe"> & {
|
|
340
|
+
subscribe: {
|
|
341
|
+
(listener: (selectedState: PlaybackQueueStore, previousSelectedState: PlaybackQueueStore) => void): () => void;
|
|
342
|
+
<U>(selector: (state: PlaybackQueueStore) => U, listener: (selectedState: U, previousSelectedState: U) => void, options?: {
|
|
343
|
+
equalityFn?: ((a: U, b: U) => boolean) | undefined;
|
|
344
|
+
fireImmediately?: boolean;
|
|
345
|
+
} | undefined): () => void;
|
|
346
|
+
};
|
|
347
|
+
}>;
|
|
348
|
+
/**
|
|
349
|
+
* 获取播放队列操作方法
|
|
350
|
+
*
|
|
351
|
+
* 使用 shallow 比较,避免不必要的重渲染。
|
|
352
|
+
* 返回的方法引用稳定,可以安全地用作依赖项。
|
|
353
|
+
*
|
|
354
|
+
* @returns 所有操作方法
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* ```tsx
|
|
358
|
+
* const { play, stop, next, prev, toggleMute, onEnded } = usePlaybackActions()
|
|
359
|
+
*
|
|
360
|
+
* // 在视频组件中
|
|
361
|
+
* <video
|
|
362
|
+
* onEnded={onEnded}
|
|
363
|
+
* onClick={() => play('home', item.id)}
|
|
364
|
+
* />
|
|
365
|
+
*
|
|
366
|
+
* // 控制按钮
|
|
367
|
+
* <button onClick={prev}>上一个</button>
|
|
368
|
+
* <button onClick={next}>下一个</button>
|
|
369
|
+
* <button onClick={toggleMute}>静音</button>
|
|
370
|
+
* ```
|
|
371
|
+
*/
|
|
372
|
+
export declare function usePlaybackActions(): PlaybackQueueActions;
|
|
373
|
+
/**
|
|
374
|
+
* 判断指定项目是否正在播放
|
|
375
|
+
*
|
|
376
|
+
* 选择性订阅,只有当该项目的播放状态变化时才会重渲染。
|
|
377
|
+
* 这是性能最优的播放状态判断方式。
|
|
378
|
+
*
|
|
379
|
+
* @param id - 项目 ID
|
|
380
|
+
* @returns 是否正在播放
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* ```tsx
|
|
384
|
+
* const VideoCard = ({ item }) => {
|
|
385
|
+
* const isPlaying = usePlaybackIsPlaying(item.id)
|
|
386
|
+
*
|
|
387
|
+
* return (
|
|
388
|
+
* <video autoPlay={isPlaying} muted />
|
|
389
|
+
* )
|
|
390
|
+
* }
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
export declare function usePlaybackIsPlaying(id: string): boolean;
|
|
394
|
+
/**
|
|
395
|
+
* 获取静音状态
|
|
396
|
+
*
|
|
397
|
+
* @returns 是否静音
|
|
398
|
+
*
|
|
399
|
+
* @example
|
|
400
|
+
* ```tsx
|
|
401
|
+
* const muted = usePlaybackMuted()
|
|
402
|
+
*
|
|
403
|
+
* <video muted={muted} />
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
export declare function usePlaybackMuted(): boolean;
|
|
407
|
+
/**
|
|
408
|
+
* 获取当前播放的项目 ID
|
|
409
|
+
*
|
|
410
|
+
* @returns 当前播放的 ID,没有播放时为 null
|
|
411
|
+
*
|
|
412
|
+
* @example
|
|
413
|
+
* ```tsx
|
|
414
|
+
* const currentId = usePlaybackCurrentId()
|
|
415
|
+
*
|
|
416
|
+
* if (currentId) {
|
|
417
|
+
* console.log('正在播放:', currentId)
|
|
418
|
+
* }
|
|
419
|
+
* ```
|
|
420
|
+
*/
|
|
421
|
+
export declare function usePlaybackCurrentId(): string | null;
|
|
422
|
+
/**
|
|
423
|
+
* 获取当前激活的 source 名称
|
|
424
|
+
*
|
|
425
|
+
* @returns source 名称,没有激活时为 null
|
|
426
|
+
*
|
|
427
|
+
* @example
|
|
428
|
+
* ```tsx
|
|
429
|
+
* const activeSource = usePlaybackActiveSource()
|
|
430
|
+
*
|
|
431
|
+
* // 高亮当前激活的 Tab
|
|
432
|
+
* <Tab active={activeSource === 'home'}>首页</Tab>
|
|
433
|
+
* ```
|
|
434
|
+
*/
|
|
435
|
+
export declare function usePlaybackActiveSource(): string | null;
|
|
436
|
+
/**
|
|
437
|
+
* 获取循环播放状态
|
|
438
|
+
*
|
|
439
|
+
* @returns 是否循环播放
|
|
440
|
+
*
|
|
441
|
+
* @example
|
|
442
|
+
* ```tsx
|
|
443
|
+
* const loop = usePlaybackLoop()
|
|
444
|
+
* const { setLoop } = usePlaybackActions()
|
|
445
|
+
*
|
|
446
|
+
* <button onClick={() => setLoop(!loop)}>
|
|
447
|
+
* {loop ? '循环开' : '循环关'}
|
|
448
|
+
* </button>
|
|
449
|
+
* ```
|
|
450
|
+
*/
|
|
451
|
+
export declare function usePlaybackLoop(): boolean;
|
|
452
|
+
/**
|
|
453
|
+
* 获取指定 source 的项目 ID 列表
|
|
454
|
+
*
|
|
455
|
+
* @param source - source 名称
|
|
456
|
+
* @returns 项目 ID 列表,source 不存在时返回空数组
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* ```tsx
|
|
460
|
+
* const homeItems = usePlaybackSourceItems('home')
|
|
461
|
+
*
|
|
462
|
+
* console.log('首页有', homeItems.length, '个视频')
|
|
463
|
+
* ```
|
|
464
|
+
*/
|
|
465
|
+
export declare function usePlaybackSourceItems(source: string): string[];
|
|
466
|
+
/**
|
|
467
|
+
* 获取单个 item 的播放状态和静音状态(常用组合)
|
|
468
|
+
*
|
|
469
|
+
* 使用 shallow 比较,仅当 isPlaying 或 muted 变化时重渲染。
|
|
470
|
+
*
|
|
471
|
+
* @param id - 项目 ID
|
|
472
|
+
* @returns { isPlaying: boolean, muted: boolean }
|
|
473
|
+
*
|
|
474
|
+
* @example
|
|
475
|
+
* ```tsx
|
|
476
|
+
* const VideoCard = ({ item }) => {
|
|
477
|
+
* const { isPlaying, muted } = usePlaybackItemState(item.id)
|
|
478
|
+
* const { onEnded } = usePlaybackActions()
|
|
479
|
+
*
|
|
480
|
+
* return (
|
|
481
|
+
* <video
|
|
482
|
+
* autoPlay={isPlaying}
|
|
483
|
+
* muted={muted}
|
|
484
|
+
* onEnded={onEnded}
|
|
485
|
+
* />
|
|
486
|
+
* )
|
|
487
|
+
* }
|
|
488
|
+
* ```
|
|
489
|
+
*/
|
|
490
|
+
export declare function usePlaybackItemState(id: string): {
|
|
491
|
+
isPlaying: boolean;
|
|
492
|
+
muted: boolean;
|
|
493
|
+
};
|
|
494
|
+
/**
|
|
495
|
+
* 播放队列命名空间
|
|
496
|
+
*
|
|
497
|
+
* 非 Hook 版本的 API,用于:
|
|
498
|
+
* - 组件外部调用(如事件回调、工具函数)
|
|
499
|
+
* - 不在 React 组件中的场景
|
|
500
|
+
* - 需要命令式调用的场景
|
|
501
|
+
*
|
|
502
|
+
* @example
|
|
503
|
+
* ```tsx
|
|
504
|
+
* // 在事件回调中使用
|
|
505
|
+
* const handleClick = () => {
|
|
506
|
+
* PlaybackQueue.play('home', 'item-1')
|
|
507
|
+
* }
|
|
508
|
+
*
|
|
509
|
+
* // 在非组件代码中使用
|
|
510
|
+
* PlaybackQueue.toggleMute()
|
|
511
|
+
*
|
|
512
|
+
* // 获取当前状态快照
|
|
513
|
+
* const state = PlaybackQueue.getState()
|
|
514
|
+
* console.log('当前播放:', state.currentId)
|
|
515
|
+
*
|
|
516
|
+
* // 订阅状态变化
|
|
517
|
+
* const unsubscribe = PlaybackQueue.subscribeCurrentId((currentId, prevId) => {
|
|
518
|
+
* console.log('播放变化:', prevId, '->', currentId)
|
|
519
|
+
* })
|
|
520
|
+
* // 不再需要时取消订阅
|
|
521
|
+
* unsubscribe()
|
|
522
|
+
* ```
|
|
523
|
+
*/
|
|
524
|
+
export declare const PlaybackQueue: {
|
|
525
|
+
/**
|
|
526
|
+
* 获取当前状态快照(非响应式)
|
|
527
|
+
*
|
|
528
|
+
* @returns 当前完整状态
|
|
529
|
+
*/
|
|
530
|
+
readonly getState: () => PlaybackQueueState;
|
|
531
|
+
/**
|
|
532
|
+
* 设置某个 source 的播放列表
|
|
533
|
+
*
|
|
534
|
+
* @see {@link PlaybackQueueActions.setSource}
|
|
535
|
+
*/
|
|
536
|
+
readonly setSource: (source: string, ids: string[], autoPlay?: boolean | undefined) => void;
|
|
537
|
+
/**
|
|
538
|
+
* 向某个 source 追加项目
|
|
539
|
+
*
|
|
540
|
+
* @see {@link PlaybackQueueActions.appendToSource}
|
|
541
|
+
*/
|
|
542
|
+
readonly appendToSource: (source: string, ids: string[]) => void;
|
|
543
|
+
/**
|
|
544
|
+
* 移除某个 source
|
|
545
|
+
*
|
|
546
|
+
* @see {@link PlaybackQueueActions.removeSource}
|
|
547
|
+
*/
|
|
548
|
+
readonly removeSource: (source: string) => void;
|
|
549
|
+
/**
|
|
550
|
+
* 播放指定项目
|
|
551
|
+
*
|
|
552
|
+
* @see {@link PlaybackQueueActions.play}
|
|
553
|
+
*/
|
|
554
|
+
readonly play: (source: string, id: string) => void;
|
|
555
|
+
/**
|
|
556
|
+
* 播放指定项目(自动查找 source)
|
|
557
|
+
*
|
|
558
|
+
* @see {@link PlaybackQueueActions.playById}
|
|
559
|
+
*/
|
|
560
|
+
readonly playById: (id: string) => void;
|
|
561
|
+
/**
|
|
562
|
+
* 播放下一个
|
|
563
|
+
*
|
|
564
|
+
* @see {@link PlaybackQueueActions.next}
|
|
565
|
+
*/
|
|
566
|
+
readonly next: () => void;
|
|
567
|
+
/**
|
|
568
|
+
* 播放上一个
|
|
569
|
+
*
|
|
570
|
+
* @see {@link PlaybackQueueActions.prev}
|
|
571
|
+
*/
|
|
572
|
+
readonly prev: () => void;
|
|
573
|
+
/**
|
|
574
|
+
* 停止播放
|
|
575
|
+
*
|
|
576
|
+
* @see {@link PlaybackQueueActions.stop}
|
|
577
|
+
*/
|
|
578
|
+
readonly stop: () => void;
|
|
579
|
+
/**
|
|
580
|
+
* 播放完成回调
|
|
581
|
+
*
|
|
582
|
+
* @see {@link PlaybackQueueActions.onEnded}
|
|
583
|
+
*/
|
|
584
|
+
readonly onEnded: () => void;
|
|
585
|
+
/**
|
|
586
|
+
* 设置静音状态
|
|
587
|
+
*
|
|
588
|
+
* @see {@link PlaybackQueueActions.setMuted}
|
|
589
|
+
*/
|
|
590
|
+
readonly setMuted: (muted: boolean) => void;
|
|
591
|
+
/**
|
|
592
|
+
* 切换静音状态
|
|
593
|
+
*
|
|
594
|
+
* @see {@link PlaybackQueueActions.toggleMute}
|
|
595
|
+
*/
|
|
596
|
+
readonly toggleMute: () => void;
|
|
597
|
+
/**
|
|
598
|
+
* 设置循环播放
|
|
599
|
+
*
|
|
600
|
+
* @see {@link PlaybackQueueActions.setLoop}
|
|
601
|
+
*/
|
|
602
|
+
readonly setLoop: (loop: boolean) => void;
|
|
603
|
+
/**
|
|
604
|
+
* 重置所有状态
|
|
605
|
+
*
|
|
606
|
+
* @see {@link PlaybackQueueActions.reset}
|
|
607
|
+
*/
|
|
608
|
+
readonly reset: () => void;
|
|
609
|
+
/**
|
|
610
|
+
* 订阅当前播放 ID 变化
|
|
611
|
+
*
|
|
612
|
+
* @param callback - 回调函数,参数为 (新值, 旧值)
|
|
613
|
+
* @returns 取消订阅函数
|
|
614
|
+
*
|
|
615
|
+
* @example
|
|
616
|
+
* ```ts
|
|
617
|
+
* const unsubscribe = PlaybackQueue.subscribeCurrentId((currentId, prevId) => {
|
|
618
|
+
* console.log('播放变化:', prevId, '->', currentId)
|
|
619
|
+
* })
|
|
620
|
+
*
|
|
621
|
+
* // 清理时取消订阅
|
|
622
|
+
* unsubscribe()
|
|
623
|
+
* ```
|
|
624
|
+
*/
|
|
625
|
+
readonly subscribeCurrentId: (callback: (currentId: string | null, prevId: string | null) => void) => () => void;
|
|
626
|
+
/**
|
|
627
|
+
* 订阅 activeSource 变化
|
|
628
|
+
*
|
|
629
|
+
* @param callback - 回调函数,参数为 (新值, 旧值)
|
|
630
|
+
* @returns 取消订阅函数
|
|
631
|
+
*/
|
|
632
|
+
readonly subscribeActiveSource: (callback: (source: string | null, prevSource: string | null) => void) => () => void;
|
|
633
|
+
/**
|
|
634
|
+
* 订阅静音状态变化
|
|
635
|
+
*
|
|
636
|
+
* @param callback - 回调函数,参数为 (新值, 旧值)
|
|
637
|
+
* @returns 取消订阅函数
|
|
638
|
+
*/
|
|
639
|
+
readonly subscribeMuted: (callback: (muted: boolean, prevMuted: boolean) => void) => () => void;
|
|
640
|
+
};
|
|
641
|
+
export {};
|
|
642
|
+
//# sourceMappingURL=playback-queue-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"playback-queue-store.d.ts","sourceRoot":"","sources":["../../src/media/playback-queue-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview 播放队列状态管理 (Zustand)
|
|
3
|
+
*
|
|
4
|
+
* 支持多 Tab / 多页面场景,全局只有一个视频在播放。
|
|
5
|
+
*
|
|
6
|
+
* @module playback-queue-store
|
|
7
|
+
* @see {@link ./playback-queue-store.md} 详细文档
|
|
8
|
+
*/
|
|
9
|
+
import { create } from 'zustand';
|
|
10
|
+
import { subscribeWithSelector } from 'zustand/middleware';
|
|
11
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Initial State - 初始状态
|
|
14
|
+
// ============================================================================
|
|
15
|
+
/**
|
|
16
|
+
* 初始状态
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
const initialState = {
|
|
21
|
+
sources: {},
|
|
22
|
+
itemIdToSource: {},
|
|
23
|
+
activeSource: null,
|
|
24
|
+
currentId: null,
|
|
25
|
+
muted: true, // 默认静音,符合浏览器自动播放策略
|
|
26
|
+
loop: true, // 默认循环播放
|
|
27
|
+
};
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Helper Functions - 辅助函数
|
|
30
|
+
// ============================================================================
|
|
31
|
+
/**
|
|
32
|
+
* 更新单个 source 的反向索引
|
|
33
|
+
*
|
|
34
|
+
* @param currentIndex - 当前的反向索引
|
|
35
|
+
* @param sourceName - source 名称
|
|
36
|
+
* @param oldIds - 旧的 ID 列表
|
|
37
|
+
* @param newIds - 新的 ID 列表
|
|
38
|
+
* @returns 更新后的反向索引
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
function updateReverseIndexForSource(currentIndex, sourceName, oldIds, newIds) {
|
|
43
|
+
const index = { ...currentIndex };
|
|
44
|
+
// 移除旧的映射
|
|
45
|
+
for (const id of oldIds) {
|
|
46
|
+
if (index[id] === sourceName) {
|
|
47
|
+
delete index[id];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 添加新的映射
|
|
51
|
+
for (const id of newIds) {
|
|
52
|
+
index[id] = sourceName;
|
|
53
|
+
}
|
|
54
|
+
return index;
|
|
55
|
+
}
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Store - Zustand Store 定义
|
|
58
|
+
// ============================================================================
|
|
59
|
+
/**
|
|
60
|
+
* 播放队列 Zustand Store
|
|
61
|
+
*
|
|
62
|
+
* 使用 `subscribeWithSelector` 中间件支持选择性订阅。
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```tsx
|
|
66
|
+
* // 直接使用 store(不推荐,建议使用封装的 hooks)
|
|
67
|
+
* const currentId = usePlaybackQueueStore(state => state.currentId)
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export const usePlaybackQueueStore = create()(subscribeWithSelector((set, get) => ({
|
|
71
|
+
...initialState,
|
|
72
|
+
setSource: (source, ids, autoPlay = false) => {
|
|
73
|
+
if (!source) {
|
|
74
|
+
if (process.env.NODE_ENV === 'development') {
|
|
75
|
+
console.warn('[PlaybackQueue] source name is required');
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const uniqueIds = [...new Set(ids || [])];
|
|
80
|
+
set(state => {
|
|
81
|
+
var _a;
|
|
82
|
+
const oldIds = ((_a = state.sources[source]) === null || _a === void 0 ? void 0 : _a.itemIds) || [];
|
|
83
|
+
const newSources = {
|
|
84
|
+
...state.sources,
|
|
85
|
+
[source]: { itemIds: uniqueIds },
|
|
86
|
+
};
|
|
87
|
+
const newIndex = updateReverseIndexForSource(state.itemIdToSource, source, oldIds, uniqueIds);
|
|
88
|
+
// 检查当前播放的项目是否还在列表中
|
|
89
|
+
let newCurrentId = state.currentId;
|
|
90
|
+
let newActiveSource = state.activeSource;
|
|
91
|
+
if (state.activeSource === source && state.currentId) {
|
|
92
|
+
if (!uniqueIds.includes(state.currentId)) {
|
|
93
|
+
// 当前播放的项目不在新列表中
|
|
94
|
+
newCurrentId = autoPlay && uniqueIds.length > 0 ? uniqueIds[0] : null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 如果要自动播放且当前没有在播放
|
|
98
|
+
if (autoPlay && uniqueIds.length > 0 && !state.currentId) {
|
|
99
|
+
newActiveSource = source;
|
|
100
|
+
newCurrentId = uniqueIds[0];
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
sources: newSources,
|
|
104
|
+
itemIdToSource: newIndex,
|
|
105
|
+
activeSource: newActiveSource,
|
|
106
|
+
currentId: newCurrentId,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
appendToSource: (source, ids) => {
|
|
111
|
+
if (!source || !ids || ids.length === 0)
|
|
112
|
+
return;
|
|
113
|
+
set(state => {
|
|
114
|
+
const sourceData = state.sources[source];
|
|
115
|
+
const existingIds = (sourceData === null || sourceData === void 0 ? void 0 : sourceData.itemIds) || [];
|
|
116
|
+
const existingSet = new Set(existingIds);
|
|
117
|
+
const newIds = ids.filter(id => !existingSet.has(id));
|
|
118
|
+
if (newIds.length === 0)
|
|
119
|
+
return state;
|
|
120
|
+
const allIds = [...existingIds, ...newIds];
|
|
121
|
+
const newIndex = { ...state.itemIdToSource };
|
|
122
|
+
for (const id of newIds) {
|
|
123
|
+
newIndex[id] = source;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
sources: {
|
|
127
|
+
...state.sources,
|
|
128
|
+
[source]: { itemIds: allIds },
|
|
129
|
+
},
|
|
130
|
+
itemIdToSource: newIndex,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
removeSource: (source) => {
|
|
135
|
+
set(state => {
|
|
136
|
+
const sourceData = state.sources[source];
|
|
137
|
+
if (!sourceData)
|
|
138
|
+
return state;
|
|
139
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
140
|
+
const { [source]: _removed, ...restSources } = state.sources;
|
|
141
|
+
// 清理反向索引
|
|
142
|
+
const newIndex = { ...state.itemIdToSource };
|
|
143
|
+
for (const id of sourceData.itemIds) {
|
|
144
|
+
if (newIndex[id] === source) {
|
|
145
|
+
delete newIndex[id];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// 如果移除的是当前激活的 source,停止播放
|
|
149
|
+
if (state.activeSource === source) {
|
|
150
|
+
return {
|
|
151
|
+
sources: restSources,
|
|
152
|
+
itemIdToSource: newIndex,
|
|
153
|
+
activeSource: null,
|
|
154
|
+
currentId: null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
sources: restSources,
|
|
159
|
+
itemIdToSource: newIndex,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
play: (source, id) => {
|
|
164
|
+
const { sources } = get();
|
|
165
|
+
const sourceData = sources[source];
|
|
166
|
+
if (!sourceData) {
|
|
167
|
+
if (process.env.NODE_ENV === 'development') {
|
|
168
|
+
console.warn(`[PlaybackQueue] Source "${source}" not found`);
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!sourceData.itemIds.includes(id)) {
|
|
173
|
+
if (process.env.NODE_ENV === 'development') {
|
|
174
|
+
console.warn(`[PlaybackQueue] Item "${id}" not found in source "${source}"`);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
set({
|
|
179
|
+
activeSource: source,
|
|
180
|
+
currentId: id,
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
playById: (id) => {
|
|
184
|
+
var _a;
|
|
185
|
+
const { sources, activeSource, itemIdToSource } = get();
|
|
186
|
+
// 优先检查当前 activeSource
|
|
187
|
+
if (activeSource && ((_a = sources[activeSource]) === null || _a === void 0 ? void 0 : _a.itemIds.includes(id))) {
|
|
188
|
+
set({ currentId: id });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// 使用反向索引快速查找
|
|
192
|
+
const source = itemIdToSource[id];
|
|
193
|
+
if (!source) {
|
|
194
|
+
if (process.env.NODE_ENV === 'development') {
|
|
195
|
+
console.warn(`[PlaybackQueue] Item "${id}" not found in any source`);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
set({
|
|
200
|
+
activeSource: source,
|
|
201
|
+
currentId: id,
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
next: () => {
|
|
205
|
+
const { sources, activeSource, currentId, loop } = get();
|
|
206
|
+
if (!activeSource)
|
|
207
|
+
return;
|
|
208
|
+
const sourceData = sources[activeSource];
|
|
209
|
+
if (!sourceData || sourceData.itemIds.length === 0)
|
|
210
|
+
return;
|
|
211
|
+
// 如果没有当前播放项,播放第一个
|
|
212
|
+
if (!currentId) {
|
|
213
|
+
set({ currentId: sourceData.itemIds[0] });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const currentIndex = sourceData.itemIds.indexOf(currentId);
|
|
217
|
+
if (currentIndex === -1) {
|
|
218
|
+
set({ currentId: sourceData.itemIds[0] });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const nextIndex = currentIndex + 1;
|
|
222
|
+
if (nextIndex >= sourceData.itemIds.length) {
|
|
223
|
+
// 已是最后一个
|
|
224
|
+
set({ currentId: loop ? sourceData.itemIds[0] : null });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
set({ currentId: sourceData.itemIds[nextIndex] });
|
|
228
|
+
},
|
|
229
|
+
prev: () => {
|
|
230
|
+
const { sources, activeSource, currentId, loop } = get();
|
|
231
|
+
if (!activeSource)
|
|
232
|
+
return;
|
|
233
|
+
const sourceData = sources[activeSource];
|
|
234
|
+
if (!sourceData || sourceData.itemIds.length === 0)
|
|
235
|
+
return;
|
|
236
|
+
if (!currentId) {
|
|
237
|
+
set({ currentId: sourceData.itemIds[sourceData.itemIds.length - 1] });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const currentIndex = sourceData.itemIds.indexOf(currentId);
|
|
241
|
+
if (currentIndex === -1) {
|
|
242
|
+
set({ currentId: sourceData.itemIds[0] });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const prevIndex = currentIndex - 1;
|
|
246
|
+
if (prevIndex < 0) {
|
|
247
|
+
set({
|
|
248
|
+
currentId: loop ? sourceData.itemIds[sourceData.itemIds.length - 1] : null
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
set({ currentId: sourceData.itemIds[prevIndex] });
|
|
253
|
+
},
|
|
254
|
+
stop: () => {
|
|
255
|
+
set({ currentId: null });
|
|
256
|
+
},
|
|
257
|
+
onEnded: () => {
|
|
258
|
+
get().next();
|
|
259
|
+
},
|
|
260
|
+
setMuted: (muted) => {
|
|
261
|
+
set({ muted });
|
|
262
|
+
},
|
|
263
|
+
toggleMute: () => {
|
|
264
|
+
set(state => ({ muted: !state.muted }));
|
|
265
|
+
},
|
|
266
|
+
setLoop: (loop) => {
|
|
267
|
+
set({ loop });
|
|
268
|
+
},
|
|
269
|
+
reset: () => {
|
|
270
|
+
set(initialState);
|
|
271
|
+
},
|
|
272
|
+
})));
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// Selectors - 选择器(静态引用,避免重复创建)
|
|
275
|
+
// ============================================================================
|
|
276
|
+
/** @internal */
|
|
277
|
+
const selectActions = (state) => ({
|
|
278
|
+
setSource: state.setSource,
|
|
279
|
+
appendToSource: state.appendToSource,
|
|
280
|
+
removeSource: state.removeSource,
|
|
281
|
+
play: state.play,
|
|
282
|
+
playById: state.playById,
|
|
283
|
+
next: state.next,
|
|
284
|
+
prev: state.prev,
|
|
285
|
+
stop: state.stop,
|
|
286
|
+
onEnded: state.onEnded,
|
|
287
|
+
setMuted: state.setMuted,
|
|
288
|
+
toggleMute: state.toggleMute,
|
|
289
|
+
setLoop: state.setLoop,
|
|
290
|
+
reset: state.reset,
|
|
291
|
+
});
|
|
292
|
+
/** @internal */
|
|
293
|
+
const selectMuted = (state) => state.muted;
|
|
294
|
+
/** @internal */
|
|
295
|
+
const selectCurrentId = (state) => state.currentId;
|
|
296
|
+
/** @internal */
|
|
297
|
+
const selectActiveSource = (state) => state.activeSource;
|
|
298
|
+
/** @internal */
|
|
299
|
+
const selectLoop = (state) => state.loop;
|
|
300
|
+
// ============================================================================
|
|
301
|
+
// Hooks - React Hooks(选择性订阅,性能优化)
|
|
302
|
+
// ============================================================================
|
|
303
|
+
/**
|
|
304
|
+
* 获取播放队列操作方法
|
|
305
|
+
*
|
|
306
|
+
* 使用 shallow 比较,避免不必要的重渲染。
|
|
307
|
+
* 返回的方法引用稳定,可以安全地用作依赖项。
|
|
308
|
+
*
|
|
309
|
+
* @returns 所有操作方法
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```tsx
|
|
313
|
+
* const { play, stop, next, prev, toggleMute, onEnded } = usePlaybackActions()
|
|
314
|
+
*
|
|
315
|
+
* // 在视频组件中
|
|
316
|
+
* <video
|
|
317
|
+
* onEnded={onEnded}
|
|
318
|
+
* onClick={() => play('home', item.id)}
|
|
319
|
+
* />
|
|
320
|
+
*
|
|
321
|
+
* // 控制按钮
|
|
322
|
+
* <button onClick={prev}>上一个</button>
|
|
323
|
+
* <button onClick={next}>下一个</button>
|
|
324
|
+
* <button onClick={toggleMute}>静音</button>
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
export function usePlaybackActions() {
|
|
328
|
+
return usePlaybackQueueStore(useShallow(selectActions));
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 判断指定项目是否正在播放
|
|
332
|
+
*
|
|
333
|
+
* 选择性订阅,只有当该项目的播放状态变化时才会重渲染。
|
|
334
|
+
* 这是性能最优的播放状态判断方式。
|
|
335
|
+
*
|
|
336
|
+
* @param id - 项目 ID
|
|
337
|
+
* @returns 是否正在播放
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* ```tsx
|
|
341
|
+
* const VideoCard = ({ item }) => {
|
|
342
|
+
* const isPlaying = usePlaybackIsPlaying(item.id)
|
|
343
|
+
*
|
|
344
|
+
* return (
|
|
345
|
+
* <video autoPlay={isPlaying} muted />
|
|
346
|
+
* )
|
|
347
|
+
* }
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
350
|
+
export function usePlaybackIsPlaying(id) {
|
|
351
|
+
return usePlaybackQueueStore(state => state.currentId === id);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* 获取静音状态
|
|
355
|
+
*
|
|
356
|
+
* @returns 是否静音
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* ```tsx
|
|
360
|
+
* const muted = usePlaybackMuted()
|
|
361
|
+
*
|
|
362
|
+
* <video muted={muted} />
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
export function usePlaybackMuted() {
|
|
366
|
+
return usePlaybackQueueStore(selectMuted);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 获取当前播放的项目 ID
|
|
370
|
+
*
|
|
371
|
+
* @returns 当前播放的 ID,没有播放时为 null
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* ```tsx
|
|
375
|
+
* const currentId = usePlaybackCurrentId()
|
|
376
|
+
*
|
|
377
|
+
* if (currentId) {
|
|
378
|
+
* console.log('正在播放:', currentId)
|
|
379
|
+
* }
|
|
380
|
+
* ```
|
|
381
|
+
*/
|
|
382
|
+
export function usePlaybackCurrentId() {
|
|
383
|
+
return usePlaybackQueueStore(selectCurrentId);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* 获取当前激活的 source 名称
|
|
387
|
+
*
|
|
388
|
+
* @returns source 名称,没有激活时为 null
|
|
389
|
+
*
|
|
390
|
+
* @example
|
|
391
|
+
* ```tsx
|
|
392
|
+
* const activeSource = usePlaybackActiveSource()
|
|
393
|
+
*
|
|
394
|
+
* // 高亮当前激活的 Tab
|
|
395
|
+
* <Tab active={activeSource === 'home'}>首页</Tab>
|
|
396
|
+
* ```
|
|
397
|
+
*/
|
|
398
|
+
export function usePlaybackActiveSource() {
|
|
399
|
+
return usePlaybackQueueStore(selectActiveSource);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* 获取循环播放状态
|
|
403
|
+
*
|
|
404
|
+
* @returns 是否循环播放
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* ```tsx
|
|
408
|
+
* const loop = usePlaybackLoop()
|
|
409
|
+
* const { setLoop } = usePlaybackActions()
|
|
410
|
+
*
|
|
411
|
+
* <button onClick={() => setLoop(!loop)}>
|
|
412
|
+
* {loop ? '循环开' : '循环关'}
|
|
413
|
+
* </button>
|
|
414
|
+
* ```
|
|
415
|
+
*/
|
|
416
|
+
export function usePlaybackLoop() {
|
|
417
|
+
return usePlaybackQueueStore(selectLoop);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* 获取指定 source 的项目 ID 列表
|
|
421
|
+
*
|
|
422
|
+
* @param source - source 名称
|
|
423
|
+
* @returns 项目 ID 列表,source 不存在时返回空数组
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* ```tsx
|
|
427
|
+
* const homeItems = usePlaybackSourceItems('home')
|
|
428
|
+
*
|
|
429
|
+
* console.log('首页有', homeItems.length, '个视频')
|
|
430
|
+
* ```
|
|
431
|
+
*/
|
|
432
|
+
export function usePlaybackSourceItems(source) {
|
|
433
|
+
return usePlaybackQueueStore(state => { var _a, _b; return (_b = (_a = state.sources[source]) === null || _a === void 0 ? void 0 : _a.itemIds) !== null && _b !== void 0 ? _b : []; });
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* 获取单个 item 的播放状态和静音状态(常用组合)
|
|
437
|
+
*
|
|
438
|
+
* 使用 shallow 比较,仅当 isPlaying 或 muted 变化时重渲染。
|
|
439
|
+
*
|
|
440
|
+
* @param id - 项目 ID
|
|
441
|
+
* @returns { isPlaying: boolean, muted: boolean }
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* ```tsx
|
|
445
|
+
* const VideoCard = ({ item }) => {
|
|
446
|
+
* const { isPlaying, muted } = usePlaybackItemState(item.id)
|
|
447
|
+
* const { onEnded } = usePlaybackActions()
|
|
448
|
+
*
|
|
449
|
+
* return (
|
|
450
|
+
* <video
|
|
451
|
+
* autoPlay={isPlaying}
|
|
452
|
+
* muted={muted}
|
|
453
|
+
* onEnded={onEnded}
|
|
454
|
+
* />
|
|
455
|
+
* )
|
|
456
|
+
* }
|
|
457
|
+
* ```
|
|
458
|
+
*/
|
|
459
|
+
export function usePlaybackItemState(id) {
|
|
460
|
+
return usePlaybackQueueStore(useShallow(state => ({
|
|
461
|
+
isPlaying: state.currentId === id,
|
|
462
|
+
muted: state.muted,
|
|
463
|
+
})));
|
|
464
|
+
}
|
|
465
|
+
// ============================================================================
|
|
466
|
+
// Non-Hook API - 非 Hook API(用于组件外部或副作用)
|
|
467
|
+
// ============================================================================
|
|
468
|
+
/**
|
|
469
|
+
* 播放队列命名空间
|
|
470
|
+
*
|
|
471
|
+
* 非 Hook 版本的 API,用于:
|
|
472
|
+
* - 组件外部调用(如事件回调、工具函数)
|
|
473
|
+
* - 不在 React 组件中的场景
|
|
474
|
+
* - 需要命令式调用的场景
|
|
475
|
+
*
|
|
476
|
+
* @example
|
|
477
|
+
* ```tsx
|
|
478
|
+
* // 在事件回调中使用
|
|
479
|
+
* const handleClick = () => {
|
|
480
|
+
* PlaybackQueue.play('home', 'item-1')
|
|
481
|
+
* }
|
|
482
|
+
*
|
|
483
|
+
* // 在非组件代码中使用
|
|
484
|
+
* PlaybackQueue.toggleMute()
|
|
485
|
+
*
|
|
486
|
+
* // 获取当前状态快照
|
|
487
|
+
* const state = PlaybackQueue.getState()
|
|
488
|
+
* console.log('当前播放:', state.currentId)
|
|
489
|
+
*
|
|
490
|
+
* // 订阅状态变化
|
|
491
|
+
* const unsubscribe = PlaybackQueue.subscribeCurrentId((currentId, prevId) => {
|
|
492
|
+
* console.log('播放变化:', prevId, '->', currentId)
|
|
493
|
+
* })
|
|
494
|
+
* // 不再需要时取消订阅
|
|
495
|
+
* unsubscribe()
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
export const PlaybackQueue = {
|
|
499
|
+
/**
|
|
500
|
+
* 获取当前状态快照(非响应式)
|
|
501
|
+
*
|
|
502
|
+
* @returns 当前完整状态
|
|
503
|
+
*/
|
|
504
|
+
getState: () => usePlaybackQueueStore.getState(),
|
|
505
|
+
/**
|
|
506
|
+
* 设置某个 source 的播放列表
|
|
507
|
+
*
|
|
508
|
+
* @see {@link PlaybackQueueActions.setSource}
|
|
509
|
+
*/
|
|
510
|
+
setSource: (...args) => usePlaybackQueueStore.getState().setSource(...args),
|
|
511
|
+
/**
|
|
512
|
+
* 向某个 source 追加项目
|
|
513
|
+
*
|
|
514
|
+
* @see {@link PlaybackQueueActions.appendToSource}
|
|
515
|
+
*/
|
|
516
|
+
appendToSource: (...args) => usePlaybackQueueStore.getState().appendToSource(...args),
|
|
517
|
+
/**
|
|
518
|
+
* 移除某个 source
|
|
519
|
+
*
|
|
520
|
+
* @see {@link PlaybackQueueActions.removeSource}
|
|
521
|
+
*/
|
|
522
|
+
removeSource: (...args) => usePlaybackQueueStore.getState().removeSource(...args),
|
|
523
|
+
/**
|
|
524
|
+
* 播放指定项目
|
|
525
|
+
*
|
|
526
|
+
* @see {@link PlaybackQueueActions.play}
|
|
527
|
+
*/
|
|
528
|
+
play: (...args) => usePlaybackQueueStore.getState().play(...args),
|
|
529
|
+
/**
|
|
530
|
+
* 播放指定项目(自动查找 source)
|
|
531
|
+
*
|
|
532
|
+
* @see {@link PlaybackQueueActions.playById}
|
|
533
|
+
*/
|
|
534
|
+
playById: (...args) => usePlaybackQueueStore.getState().playById(...args),
|
|
535
|
+
/**
|
|
536
|
+
* 播放下一个
|
|
537
|
+
*
|
|
538
|
+
* @see {@link PlaybackQueueActions.next}
|
|
539
|
+
*/
|
|
540
|
+
next: () => usePlaybackQueueStore.getState().next(),
|
|
541
|
+
/**
|
|
542
|
+
* 播放上一个
|
|
543
|
+
*
|
|
544
|
+
* @see {@link PlaybackQueueActions.prev}
|
|
545
|
+
*/
|
|
546
|
+
prev: () => usePlaybackQueueStore.getState().prev(),
|
|
547
|
+
/**
|
|
548
|
+
* 停止播放
|
|
549
|
+
*
|
|
550
|
+
* @see {@link PlaybackQueueActions.stop}
|
|
551
|
+
*/
|
|
552
|
+
stop: () => usePlaybackQueueStore.getState().stop(),
|
|
553
|
+
/**
|
|
554
|
+
* 播放完成回调
|
|
555
|
+
*
|
|
556
|
+
* @see {@link PlaybackQueueActions.onEnded}
|
|
557
|
+
*/
|
|
558
|
+
onEnded: () => usePlaybackQueueStore.getState().onEnded(),
|
|
559
|
+
/**
|
|
560
|
+
* 设置静音状态
|
|
561
|
+
*
|
|
562
|
+
* @see {@link PlaybackQueueActions.setMuted}
|
|
563
|
+
*/
|
|
564
|
+
setMuted: (...args) => usePlaybackQueueStore.getState().setMuted(...args),
|
|
565
|
+
/**
|
|
566
|
+
* 切换静音状态
|
|
567
|
+
*
|
|
568
|
+
* @see {@link PlaybackQueueActions.toggleMute}
|
|
569
|
+
*/
|
|
570
|
+
toggleMute: () => usePlaybackQueueStore.getState().toggleMute(),
|
|
571
|
+
/**
|
|
572
|
+
* 设置循环播放
|
|
573
|
+
*
|
|
574
|
+
* @see {@link PlaybackQueueActions.setLoop}
|
|
575
|
+
*/
|
|
576
|
+
setLoop: (...args) => usePlaybackQueueStore.getState().setLoop(...args),
|
|
577
|
+
/**
|
|
578
|
+
* 重置所有状态
|
|
579
|
+
*
|
|
580
|
+
* @see {@link PlaybackQueueActions.reset}
|
|
581
|
+
*/
|
|
582
|
+
reset: () => usePlaybackQueueStore.getState().reset(),
|
|
583
|
+
/**
|
|
584
|
+
* 订阅当前播放 ID 变化
|
|
585
|
+
*
|
|
586
|
+
* @param callback - 回调函数,参数为 (新值, 旧值)
|
|
587
|
+
* @returns 取消订阅函数
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* ```ts
|
|
591
|
+
* const unsubscribe = PlaybackQueue.subscribeCurrentId((currentId, prevId) => {
|
|
592
|
+
* console.log('播放变化:', prevId, '->', currentId)
|
|
593
|
+
* })
|
|
594
|
+
*
|
|
595
|
+
* // 清理时取消订阅
|
|
596
|
+
* unsubscribe()
|
|
597
|
+
* ```
|
|
598
|
+
*/
|
|
599
|
+
subscribeCurrentId: (callback) => usePlaybackQueueStore.subscribe(state => state.currentId, callback),
|
|
600
|
+
/**
|
|
601
|
+
* 订阅 activeSource 变化
|
|
602
|
+
*
|
|
603
|
+
* @param callback - 回调函数,参数为 (新值, 旧值)
|
|
604
|
+
* @returns 取消订阅函数
|
|
605
|
+
*/
|
|
606
|
+
subscribeActiveSource: (callback) => usePlaybackQueueStore.subscribe(state => state.activeSource, callback),
|
|
607
|
+
/**
|
|
608
|
+
* 订阅静音状态变化
|
|
609
|
+
*
|
|
610
|
+
* @param callback - 回调函数,参数为 (新值, 旧值)
|
|
611
|
+
* @returns 取消订阅函数
|
|
612
|
+
*/
|
|
613
|
+
subscribeMuted: (callback) => usePlaybackQueueStore.subscribe(state => state.muted, callback),
|
|
614
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xxf_react",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"react": ">=18"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
+
"zustand": "^5.0.11",
|
|
68
69
|
"@microsoft/fetch-event-source": "^2.0.1",
|
|
69
70
|
"@use-gesture/react": "^10.3.1",
|
|
70
71
|
"bowser": "^2.14.1",
|