@xhub-reel/feed 0.1.0

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/index.mjs ADDED
@@ -0,0 +1,1829 @@
1
+ import { createContext, forwardRef, useState, useRef, useEffect, useCallback, useImperativeHandle, useContext, useMemo } from 'react';
2
+ import { durations, easings, radii, zIndices, colors, shadows, fontWeights, fontSizes, spacing, mergeStyles, useFeedStore, MEMORY_CONFIG, VIDEO_ACTIVATION } from '@xhub-reel/core';
3
+ import { useVerticalSwipe, useVideoGestures } from '@xhub-reel/gestures';
4
+ import { ActionBar, Timeline, PlayPauseOverlay, DoubleTapHeart, useDoubleTapHeart, usePlayer } from '@xhub-reel/player';
5
+ export { usePreload } from '@xhub-reel/player';
6
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
7
+ import { useXHubReelConfig, createXHubReelApiClient, queryKeys } from '@xhub-reel/core/api';
8
+ import { useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
9
+
10
+ // src/components/VideoFeed.tsx
11
+ var videoFeedItemStyles = {
12
+ container: {
13
+ position: "relative",
14
+ width: "100%",
15
+ height: "100%",
16
+ backgroundColor: colors.background
17
+ },
18
+ video: {
19
+ position: "absolute",
20
+ inset: 0,
21
+ width: "100%",
22
+ height: "100%",
23
+ objectFit: "contain"
24
+ },
25
+ placeholder: {
26
+ position: "absolute",
27
+ inset: 0,
28
+ backgroundSize: "cover",
29
+ backgroundPosition: "center"
30
+ },
31
+ // Tap area for play/pause
32
+ tapArea: {
33
+ zIndex: zIndices.base},
34
+ // Pause overlay icon
35
+ pauseOverlay: {
36
+ zIndex: zIndices.overlay},
37
+ pauseIconWrapper: {
38
+ borderRadius: radii.full,
39
+ transition: `opacity ${durations.normal}ms ${easings.xhubReel}, transform ${durations.normal}ms ${easings.xhubReel}`
40
+ }
41
+ };
42
+ var VideoFeedItemContext = createContext(null);
43
+ function useVideoFeedItemContext() {
44
+ const context = useContext(VideoFeedItemContext);
45
+ if (!context) {
46
+ throw new Error(
47
+ "VideoFeedItem compound components must be used within a VideoFeedItem"
48
+ );
49
+ }
50
+ return context;
51
+ }
52
+ function useVideoVisibility({
53
+ elementRef,
54
+ activateThreshold = VIDEO_ACTIVATION.ACTIVATION_THRESHOLD,
55
+ deactivateThreshold = VIDEO_ACTIVATION.DEACTIVATION_THRESHOLD,
56
+ rootMargin = "0px",
57
+ onVisibilityChange
58
+ }) {
59
+ const [isVisible, setIsVisible] = useState(false);
60
+ const [isActive, setIsActive] = useState(false);
61
+ const [visibilityRatio, setVisibilityRatio] = useState(0);
62
+ const observerRef = useRef(null);
63
+ useEffect(() => {
64
+ const element = elementRef.current;
65
+ if (!element) return;
66
+ if (observerRef.current) {
67
+ observerRef.current.disconnect();
68
+ }
69
+ const thresholds = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
70
+ observerRef.current = new IntersectionObserver(
71
+ (entries) => {
72
+ entries.forEach((entry) => {
73
+ const ratio = entry.intersectionRatio;
74
+ setVisibilityRatio(ratio);
75
+ setIsVisible(ratio > 0);
76
+ if (ratio >= activateThreshold) {
77
+ setIsActive(true);
78
+ } else if (ratio < deactivateThreshold) {
79
+ setIsActive(false);
80
+ }
81
+ onVisibilityChange?.(ratio > 0, ratio);
82
+ });
83
+ },
84
+ {
85
+ threshold: thresholds,
86
+ rootMargin
87
+ }
88
+ );
89
+ observerRef.current.observe(element);
90
+ return () => {
91
+ observerRef.current?.disconnect();
92
+ };
93
+ }, [elementRef, activateThreshold, deactivateThreshold, rootMargin, onVisibilityChange]);
94
+ return {
95
+ isVisible,
96
+ isActive,
97
+ visibilityRatio
98
+ };
99
+ }
100
+
101
+ // src/hooks/useVideoActivation.ts
102
+ function useVideoActivation({
103
+ containerRef,
104
+ videoRef,
105
+ isCurrentVideo = false,
106
+ onActivate,
107
+ onDeactivate,
108
+ autoActivate = true,
109
+ trackVisibility = false,
110
+ onVisibilityChange
111
+ }) {
112
+ const wasActiveRef = useRef(false);
113
+ const {
114
+ isVisible: observerIsVisible,
115
+ isActive: observerIsActive,
116
+ visibilityRatio: observerRatio
117
+ } = useVideoVisibility({
118
+ elementRef: containerRef ?? { current: null },
119
+ onVisibilityChange: trackVisibility ? onVisibilityChange : void 0
120
+ });
121
+ const effectiveIsActive = trackVisibility ? observerIsActive : isCurrentVideo;
122
+ useEffect(() => {
123
+ if (!autoActivate) return;
124
+ if (effectiveIsActive && !wasActiveRef.current) {
125
+ wasActiveRef.current = true;
126
+ onActivate?.();
127
+ } else if (!effectiveIsActive && wasActiveRef.current) {
128
+ wasActiveRef.current = false;
129
+ onDeactivate?.();
130
+ }
131
+ }, [effectiveIsActive, onActivate, onDeactivate, autoActivate]);
132
+ const activate = useCallback(() => {
133
+ const video = videoRef.current;
134
+ if (video) {
135
+ video.play().catch(() => {
136
+ video.muted = true;
137
+ video.play().catch(() => {
138
+ });
139
+ });
140
+ }
141
+ onActivate?.();
142
+ }, [videoRef, onActivate]);
143
+ const deactivate = useCallback(() => {
144
+ const video = videoRef.current;
145
+ if (video) {
146
+ video.pause();
147
+ video.currentTime = 0;
148
+ }
149
+ onDeactivate?.();
150
+ }, [videoRef, onDeactivate]);
151
+ return {
152
+ isActive: effectiveIsActive,
153
+ // Only meaningful when trackVisibility is enabled
154
+ isVisible: trackVisibility ? observerIsVisible : isCurrentVideo,
155
+ visibilityRatio: trackVisibility ? observerRatio : isCurrentVideo ? 1 : 0,
156
+ activate,
157
+ deactivate
158
+ };
159
+ }
160
+ var MemoryManager = class {
161
+ entries = /* @__PURE__ */ new Map();
162
+ listeners = /* @__PURE__ */ new Set();
163
+ memoryWarningThreshold;
164
+ constructor() {
165
+ this.memoryWarningThreshold = MEMORY_CONFIG?.MAX_TOTAL_MEMORY ? MEMORY_CONFIG.MAX_TOTAL_MEMORY / (1024 * 1024) : 150;
166
+ this.setupMemoryPressureListener();
167
+ }
168
+ /**
169
+ * Register a video
170
+ */
171
+ register(videoId, estimatedSizeMB = 10) {
172
+ this.entries.set(videoId, {
173
+ videoId,
174
+ inDom: false,
175
+ hasDecodedFrames: false,
176
+ estimatedSizeMB,
177
+ lastAccessed: Date.now()
178
+ });
179
+ this.notifyListeners();
180
+ }
181
+ /**
182
+ * Unregister a video
183
+ */
184
+ unregister(videoId) {
185
+ this.entries.delete(videoId);
186
+ this.notifyListeners();
187
+ }
188
+ /**
189
+ * Mark video as in DOM
190
+ */
191
+ setInDom(videoId, inDom) {
192
+ const entry = this.entries.get(videoId);
193
+ if (entry) {
194
+ entry.inDom = inDom;
195
+ entry.lastAccessed = Date.now();
196
+ this.notifyListeners();
197
+ }
198
+ }
199
+ /**
200
+ * Mark video as having decoded frames
201
+ */
202
+ setHasDecodedFrames(videoId, hasFrames) {
203
+ const entry = this.entries.get(videoId);
204
+ if (entry) {
205
+ entry.hasDecodedFrames = hasFrames;
206
+ entry.lastAccessed = Date.now();
207
+ this.notifyListeners();
208
+ }
209
+ }
210
+ /**
211
+ * Get current memory state
212
+ */
213
+ getState() {
214
+ let videosInDom = 0;
215
+ let decodedVideos = 0;
216
+ let estimatedMemoryMB = 0;
217
+ this.entries.forEach((entry) => {
218
+ if (entry.inDom) videosInDom++;
219
+ if (entry.hasDecodedFrames) decodedVideos++;
220
+ estimatedMemoryMB += entry.estimatedSizeMB;
221
+ });
222
+ return {
223
+ videosInDom,
224
+ decodedVideos,
225
+ estimatedMemoryMB,
226
+ isLowMemory: estimatedMemoryMB > this.memoryWarningThreshold
227
+ };
228
+ }
229
+ /**
230
+ * Get videos that should be disposed (LRU)
231
+ */
232
+ getVideosToDispose() {
233
+ const state = this.getState();
234
+ const toDispose = [];
235
+ const maxInDom = MEMORY_CONFIG?.MAX_VIDEOS_IN_DOM ?? 5;
236
+ const maxDecoded = MEMORY_CONFIG?.MAX_DECODED_FRAMES ?? 3;
237
+ const sortedEntries = Array.from(this.entries.values()).sort(
238
+ (a, b) => a.lastAccessed - b.lastAccessed
239
+ );
240
+ if (state.videosInDom > maxInDom) {
241
+ const toRemove = state.videosInDom - maxInDom;
242
+ let removed = 0;
243
+ for (const entry of sortedEntries) {
244
+ if (removed >= toRemove) break;
245
+ if (entry.inDom) {
246
+ toDispose.push(entry.videoId);
247
+ removed++;
248
+ }
249
+ }
250
+ }
251
+ if (state.decodedVideos > maxDecoded) {
252
+ const toRemove = state.decodedVideos - maxDecoded;
253
+ let removed = 0;
254
+ for (const entry of sortedEntries) {
255
+ if (removed >= toRemove) break;
256
+ if (entry.hasDecodedFrames && !toDispose.includes(entry.videoId)) {
257
+ toDispose.push(entry.videoId);
258
+ removed++;
259
+ }
260
+ }
261
+ }
262
+ return toDispose;
263
+ }
264
+ /**
265
+ * Subscribe to memory state changes
266
+ */
267
+ subscribe(listener) {
268
+ this.listeners.add(listener);
269
+ return () => this.listeners.delete(listener);
270
+ }
271
+ /**
272
+ * Force cleanup
273
+ */
274
+ forceCleanup() {
275
+ const toDispose = this.getVideosToDispose();
276
+ toDispose.forEach((id) => this.unregister(id));
277
+ return toDispose;
278
+ }
279
+ notifyListeners() {
280
+ const state = this.getState();
281
+ this.listeners.forEach((listener) => listener(state));
282
+ }
283
+ setupMemoryPressureListener() {
284
+ if (typeof window === "undefined") return;
285
+ if ("memory" in performance) {
286
+ setInterval(() => {
287
+ const state = this.getState();
288
+ if (state.isLowMemory) {
289
+ console.warn("[MemoryManager] Low memory warning, forcing cleanup");
290
+ this.forceCleanup();
291
+ }
292
+ }, 5e3);
293
+ }
294
+ }
295
+ };
296
+ var memoryManager = new MemoryManager();
297
+
298
+ // src/hooks/useMemoryManager.ts
299
+ function useMemoryManager({
300
+ videoId,
301
+ estimatedSizeMB = 10,
302
+ onShouldDispose
303
+ }) {
304
+ const [memoryState, setMemoryState] = useState(
305
+ () => memoryManager.getState()
306
+ );
307
+ const [shouldDispose, setShouldDispose] = useState(false);
308
+ useEffect(() => {
309
+ memoryManager.register(videoId, estimatedSizeMB);
310
+ return () => {
311
+ memoryManager.unregister(videoId);
312
+ };
313
+ }, [videoId, estimatedSizeMB]);
314
+ useEffect(() => {
315
+ const unsubscribe = memoryManager.subscribe((state) => {
316
+ setMemoryState(state);
317
+ const toDispose = memoryManager.getVideosToDispose();
318
+ const shouldDisposeThis = toDispose.includes(videoId);
319
+ if (shouldDisposeThis && !shouldDispose) {
320
+ setShouldDispose(true);
321
+ onShouldDispose?.();
322
+ } else if (!shouldDisposeThis && shouldDispose) {
323
+ setShouldDispose(false);
324
+ }
325
+ });
326
+ return unsubscribe;
327
+ }, [videoId, shouldDispose, onShouldDispose]);
328
+ const setInDom = useCallback(
329
+ (inDom) => {
330
+ memoryManager.setInDom(videoId, inDom);
331
+ },
332
+ [videoId]
333
+ );
334
+ const setHasDecodedFrames = useCallback(
335
+ (hasFrames) => {
336
+ memoryManager.setHasDecodedFrames(videoId, hasFrames);
337
+ },
338
+ [videoId]
339
+ );
340
+ return {
341
+ memoryState,
342
+ setInDom,
343
+ setHasDecodedFrames,
344
+ shouldDispose
345
+ };
346
+ }
347
+ function useGlobalMemoryState() {
348
+ const [state, setState] = useState(() => memoryManager.getState());
349
+ useEffect(() => {
350
+ const unsubscribe = memoryManager.subscribe(setState);
351
+ return unsubscribe;
352
+ }, []);
353
+ return state;
354
+ }
355
+
356
+ // src/components/VideoFeedItem/useVideoFeedItemState.ts
357
+ function useVideoFeedItemState({
358
+ video,
359
+ isActive,
360
+ priority,
361
+ onLike,
362
+ onComment,
363
+ onShare,
364
+ onAuthorClick
365
+ }) {
366
+ const containerRef = useRef(null);
367
+ const videoRef = useRef(null);
368
+ const wasPlayingBeforeSeekRef = useRef(false);
369
+ const [showPauseOverlay, setShowPauseOverlay] = useState(false);
370
+ const [timelineExpanded, setTimelineExpanded] = useState(false);
371
+ const [isPreloaded, setIsPreloaded] = useState(false);
372
+ const {
373
+ isShowing: showHeart,
374
+ position: heartPosition,
375
+ showHeart: triggerHeart
376
+ } = useDoubleTapHeart();
377
+ const preloadConfig = useMemo(
378
+ () => ({
379
+ maxConcurrent: 2,
380
+ maxBufferSize: 10 * 1024 * 1024,
381
+ priorityLevels: 10
382
+ }),
383
+ []
384
+ );
385
+ const handleNetworkChange = useCallback((_info) => {
386
+ }, []);
387
+ const handlePowerChange = useCallback((_info) => {
388
+ }, []);
389
+ const handleAnalyticsUpdate = useCallback(
390
+ (metrics) => {
391
+ if (metrics.startupTime && metrics.startupTime > 1e3) {
392
+ console.warn("[VideoFeedItem] Slow startup:", metrics.startupTime, "ms", video.id);
393
+ }
394
+ },
395
+ [video.id]
396
+ );
397
+ const { state } = usePlayer(videoRef, containerRef, {
398
+ preferNative: true,
399
+ enableSmoothTimeUpdates: true,
400
+ networkBehavior: "feed",
401
+ powerBehavior: "moderate",
402
+ preloadConfig,
403
+ enableAnalytics: true,
404
+ onPlay: () => {
405
+ setShowPauseOverlay(false);
406
+ setTimelineExpanded(false);
407
+ },
408
+ onPause: () => {
409
+ if (!videoRef.current?.seeking) {
410
+ setShowPauseOverlay(true);
411
+ setTimelineExpanded(true);
412
+ }
413
+ },
414
+ onNetworkChange: handleNetworkChange,
415
+ onPowerChange: handlePowerChange,
416
+ onAnalyticsUpdate: handleAnalyticsUpdate
417
+ });
418
+ const play = useCallback(async () => {
419
+ const videoEl = videoRef.current;
420
+ if (videoEl) {
421
+ videoEl.muted = true;
422
+ try {
423
+ await videoEl.play();
424
+ } catch (err) {
425
+ console.warn("[VideoFeedItem] Play failed:", err.message);
426
+ }
427
+ }
428
+ }, []);
429
+ const pause = useCallback(() => {
430
+ const videoEl = videoRef.current;
431
+ if (videoEl) {
432
+ videoEl.pause();
433
+ }
434
+ }, []);
435
+ const seek = useCallback((time) => {
436
+ const videoEl = videoRef.current;
437
+ if (videoEl) {
438
+ videoEl.currentTime = time;
439
+ }
440
+ }, []);
441
+ const [isPlaying, setIsPlaying] = useState(false);
442
+ useEffect(() => {
443
+ const videoEl = videoRef.current;
444
+ if (!videoEl) return;
445
+ const handlePlay = () => setIsPlaying(true);
446
+ const handlePause = () => setIsPlaying(false);
447
+ const handleEnded = () => setIsPlaying(false);
448
+ videoEl.addEventListener("play", handlePlay);
449
+ videoEl.addEventListener("pause", handlePause);
450
+ videoEl.addEventListener("ended", handleEnded);
451
+ setIsPlaying(!videoEl.paused);
452
+ return () => {
453
+ videoEl.removeEventListener("play", handlePlay);
454
+ videoEl.removeEventListener("pause", handlePause);
455
+ videoEl.removeEventListener("ended", handleEnded);
456
+ };
457
+ }, [video.id]);
458
+ const effectiveIsPlaying = isPlaying || state.state === "playing";
459
+ const { setInDom, setHasDecodedFrames, shouldDispose } = useMemoryManager({
460
+ videoId: video.id,
461
+ onShouldDispose: () => {
462
+ pause();
463
+ if (videoRef.current) {
464
+ videoRef.current.src = "";
465
+ videoRef.current.load();
466
+ }
467
+ }
468
+ });
469
+ const shouldRenderVideo = !shouldDispose && priority !== "none";
470
+ const pendingPlayRef = useRef(false);
471
+ useVideoActivation({
472
+ videoRef,
473
+ isCurrentVideo: isActive,
474
+ onActivate: () => {
475
+ console.log("[VideoFeedItem] onActivate called, videoRef:", videoRef.current?.src);
476
+ setHasDecodedFrames(true);
477
+ if (!videoRef.current) {
478
+ console.log("[VideoFeedItem] Video element not ready, marking pending play");
479
+ pendingPlayRef.current = true;
480
+ } else {
481
+ play();
482
+ }
483
+ },
484
+ onDeactivate: () => {
485
+ console.log("[VideoFeedItem] onDeactivate called");
486
+ pendingPlayRef.current = false;
487
+ setHasDecodedFrames(false);
488
+ pause();
489
+ seek(0);
490
+ },
491
+ autoActivate: true
492
+ });
493
+ useEffect(() => {
494
+ const videoEl = videoRef.current;
495
+ if (videoEl && pendingPlayRef.current && isActive) {
496
+ console.log("[VideoFeedItem] Video element now available, executing pending play");
497
+ pendingPlayRef.current = false;
498
+ play();
499
+ }
500
+ });
501
+ useEffect(() => {
502
+ setInDom(true);
503
+ return () => setInDom(false);
504
+ }, [setInDom]);
505
+ useEffect(() => {
506
+ const videoEl = videoRef.current;
507
+ if (!videoEl) return;
508
+ setIsPreloaded(false);
509
+ const handleLoadedData = () => {
510
+ console.log("[VideoFeedItem] Video loadeddata:", video.id, { isActive, priority });
511
+ if (priority === "high" && !isActive) {
512
+ requestAnimationFrame(() => {
513
+ if (videoEl.readyState >= 2) {
514
+ videoEl.currentTime = 0.01;
515
+ setIsPreloaded(true);
516
+ console.log("[VideoFeedItem] First frame decoded (preloaded):", video.id);
517
+ }
518
+ });
519
+ } else if (isActive) {
520
+ setIsPreloaded(true);
521
+ }
522
+ };
523
+ const handleCanPlay = () => {
524
+ if (priority === "high" && !isPreloaded) {
525
+ setIsPreloaded(true);
526
+ }
527
+ };
528
+ videoEl.addEventListener("loadeddata", handleLoadedData);
529
+ videoEl.addEventListener("canplay", handleCanPlay);
530
+ if (videoEl.readyState >= 2) {
531
+ handleLoadedData();
532
+ }
533
+ return () => {
534
+ videoEl.removeEventListener("loadeddata", handleLoadedData);
535
+ videoEl.removeEventListener("canplay", handleCanPlay);
536
+ };
537
+ }, [video.id, isActive, priority, isPreloaded]);
538
+ useEffect(() => {
539
+ console.log("[VideoFeedItem] State:", {
540
+ videoId: video.id,
541
+ isActive,
542
+ priority,
543
+ shouldRenderVideo,
544
+ hasVideoElement: !!videoRef.current,
545
+ videoSrc: videoRef.current?.src
546
+ });
547
+ }, [video.id, isActive, priority, shouldRenderVideo]);
548
+ const preload = useMemo(() => {
549
+ switch (priority) {
550
+ case "high":
551
+ return "auto";
552
+ case "medium":
553
+ return "metadata";
554
+ case "low":
555
+ case "metadata":
556
+ return "none";
557
+ default:
558
+ return "none";
559
+ }
560
+ }, [priority]);
561
+ const handleSeekStart = useCallback(() => {
562
+ wasPlayingBeforeSeekRef.current = effectiveIsPlaying;
563
+ setTimelineExpanded(true);
564
+ setShowPauseOverlay(false);
565
+ pause();
566
+ }, [effectiveIsPlaying, pause]);
567
+ const handleSeekEnd = useCallback(
568
+ (time) => {
569
+ seek(time);
570
+ if (wasPlayingBeforeSeekRef.current) {
571
+ play();
572
+ setTimelineExpanded(false);
573
+ } else {
574
+ setShowPauseOverlay(true);
575
+ }
576
+ },
577
+ [seek, play]
578
+ );
579
+ const handleSingleTap = useCallback(() => {
580
+ if (effectiveIsPlaying) {
581
+ pause();
582
+ } else {
583
+ play();
584
+ }
585
+ }, [effectiveIsPlaying, play, pause]);
586
+ const handleDoubleTap = useCallback(
587
+ (_zone, position) => {
588
+ triggerHeart(position.x, position.y);
589
+ onLike?.();
590
+ },
591
+ [triggerHeart, onLike]
592
+ );
593
+ const handleLongPress = useCallback(() => {
594
+ }, []);
595
+ const gestureBindings = useVideoGestures({
596
+ onSingleTap: handleSingleTap,
597
+ onDoubleTap: handleDoubleTap,
598
+ onLongPress: handleLongPress
599
+ });
600
+ return {
601
+ video,
602
+ isActive,
603
+ shouldRenderVideo,
604
+ preload,
605
+ isPreloaded,
606
+ containerRef,
607
+ videoRef,
608
+ isPlaying: effectiveIsPlaying,
609
+ showPauseOverlay,
610
+ timelineExpanded,
611
+ play,
612
+ pause,
613
+ seek,
614
+ setShowPauseOverlay,
615
+ setTimelineExpanded,
616
+ gestureBindings,
617
+ showHeart,
618
+ heartPosition,
619
+ triggerHeart,
620
+ onLike,
621
+ onComment,
622
+ onShare,
623
+ onAuthorClick,
624
+ handleSeekStart,
625
+ handleSeekEnd
626
+ };
627
+ }
628
+ var VideoFeedItemPlayer = forwardRef(
629
+ ({ placeholder, ...props }, ref) => {
630
+ const { video, videoRef, shouldRenderVideo, preload, isPreloaded } = useVideoFeedItemContext();
631
+ if (!shouldRenderVideo) {
632
+ return placeholder ?? /* @__PURE__ */ jsx(
633
+ "div",
634
+ {
635
+ ...props,
636
+ style: {
637
+ ...videoFeedItemStyles.placeholder,
638
+ backgroundImage: `url(${video.thumbnail})`,
639
+ ...props.style
640
+ }
641
+ }
642
+ );
643
+ }
644
+ const showPoster = !isPreloaded;
645
+ return /* @__PURE__ */ jsx(
646
+ "video",
647
+ {
648
+ ref: (node) => {
649
+ if (typeof ref === "function") {
650
+ ref(node);
651
+ } else if (ref) {
652
+ ref.current = node;
653
+ }
654
+ videoRef.current = node;
655
+ },
656
+ src: video.url,
657
+ poster: showPoster ? video.thumbnail : void 0,
658
+ preload,
659
+ loop: true,
660
+ playsInline: true,
661
+ muted: true,
662
+ style: videoFeedItemStyles.video
663
+ }
664
+ );
665
+ }
666
+ );
667
+ VideoFeedItemPlayer.displayName = "VideoFeedItemPlayer";
668
+ var VideoFeedItemActions = forwardRef(
669
+ ({ onLike: onLikeProp, onComment: onCommentProp, onShare: onShareProp, ...props }, ref) => {
670
+ const {
671
+ video,
672
+ onLike: contextOnLike,
673
+ onComment: contextOnComment,
674
+ onShare: contextOnShare
675
+ } = useVideoFeedItemContext();
676
+ return /* @__PURE__ */ jsx("div", { ref, ...props, children: /* @__PURE__ */ jsx(
677
+ ActionBar,
678
+ {
679
+ avatarUrl: video.author.avatar,
680
+ likeCount: video.stats.likes,
681
+ commentCount: video.stats.comments,
682
+ shareCount: video.stats.shares,
683
+ isLiked: video.isLiked,
684
+ onLike: onLikeProp ?? contextOnLike,
685
+ onComment: onCommentProp ?? contextOnComment,
686
+ onShare: onShareProp ?? contextOnShare
687
+ }
688
+ ) });
689
+ }
690
+ );
691
+ VideoFeedItemActions.displayName = "VideoFeedItemActions";
692
+ var VideoFeedItemTimeline = forwardRef(
693
+ ({ expanded: expandedProp, ...props }, ref) => {
694
+ const {
695
+ videoRef,
696
+ shouldRenderVideo,
697
+ timelineExpanded,
698
+ setTimelineExpanded,
699
+ handleSeekStart,
700
+ handleSeekEnd
701
+ } = useVideoFeedItemContext();
702
+ if (!shouldRenderVideo) {
703
+ return null;
704
+ }
705
+ return /* @__PURE__ */ jsx("div", { ref, ...props, children: /* @__PURE__ */ jsx(
706
+ Timeline,
707
+ {
708
+ videoRef,
709
+ expanded: expandedProp ?? timelineExpanded,
710
+ onSeekStart: handleSeekStart,
711
+ onSeekEnd: handleSeekEnd,
712
+ onExpandedChange: setTimelineExpanded
713
+ }
714
+ ) });
715
+ }
716
+ );
717
+ VideoFeedItemTimeline.displayName = "VideoFeedItemTimeline";
718
+ var overlayStyles = {
719
+ container: {
720
+ position: "absolute",
721
+ bottom: 0,
722
+ left: 0,
723
+ right: 64,
724
+ // Space for ActionBar (48px + 16px spacing)
725
+ padding: spacing[4],
726
+ paddingBottom: "env(safe-area-inset-bottom, 16px)",
727
+ zIndex: zIndices.base
728
+ },
729
+ containerWithTimeline: {
730
+ paddingBottom: "calc(env(safe-area-inset-bottom, 16px) + 48px)"
731
+ },
732
+ authorButton: {
733
+ gap: spacing[2],
734
+ marginBottom: spacing[3]},
735
+ avatar: {
736
+ borderRadius: radii.full,
737
+ border: `2px solid ${colors.text}`},
738
+ username: {
739
+ color: colors.text,
740
+ fontWeight: fontWeights.semibold,
741
+ fontSize: fontSizes.sm,
742
+ textShadow: shadows.text
743
+ },
744
+ caption: {
745
+ color: colors.text,
746
+ fontSize: fontSizes.sm,
747
+ lineHeight: 1.4,
748
+ textShadow: shadows.text,
749
+ display: "-webkit-box",
750
+ WebkitLineClamp: 2,
751
+ WebkitBoxOrient: "vertical",
752
+ overflow: "hidden",
753
+ paddingBottom: spacing[1]
754
+ },
755
+ author: {
756
+ color: colors.text,
757
+ fontSize: fontSizes.sm,
758
+ fontWeight: fontWeights.semibold,
759
+ textShadow: shadows.text,
760
+ display: "-webkit-box",
761
+ WebkitLineClamp: 1,
762
+ WebkitBoxOrient: "vertical",
763
+ overflow: "hidden",
764
+ paddingBottom: spacing[1]
765
+ },
766
+ hashtags: {
767
+ display: "flex",
768
+ flexWrap: "wrap",
769
+ gap: spacing[1],
770
+ marginTop: spacing[2],
771
+ paddingBottom: spacing[1]
772
+ },
773
+ hashtag: {
774
+ color: colors.accent,
775
+ fontSize: fontSizes.sm,
776
+ fontWeight: fontWeights.medium,
777
+ textShadow: shadows.text
778
+ }
779
+ };
780
+ function VideoOverlay({
781
+ video,
782
+ timelineExpanded = false,
783
+ style,
784
+ className = ""
785
+ }) {
786
+ const containerStyles = mergeStyles(
787
+ overlayStyles.container,
788
+ timelineExpanded && overlayStyles.containerWithTimeline,
789
+ style
790
+ );
791
+ return /* @__PURE__ */ jsxs("div", { style: containerStyles, className, children: [
792
+ video.author && /* @__PURE__ */ jsx("p", { style: overlayStyles.author, children: video.author.displayName }),
793
+ video.caption && /* @__PURE__ */ jsx("p", { style: overlayStyles.caption, children: video.caption }),
794
+ video.hashtags && video.hashtags.length > 0 && /* @__PURE__ */ jsx("div", { style: overlayStyles.hashtags, children: video.hashtags.slice(0, 3).map((tag, i) => /* @__PURE__ */ jsxs("span", { style: overlayStyles.hashtag, children: [
795
+ "#",
796
+ tag
797
+ ] }, i)) })
798
+ ] });
799
+ }
800
+ var VideoFeedItemOverlay = forwardRef(
801
+ ({
802
+ showPlayPause = true,
803
+ showDoubleTapHeart = true,
804
+ showVideoInfo = true,
805
+ ...props
806
+ }, ref) => {
807
+ const {
808
+ video,
809
+ isPlaying,
810
+ showPauseOverlay,
811
+ timelineExpanded,
812
+ showHeart,
813
+ heartPosition,
814
+ onAuthorClick
815
+ } = useVideoFeedItemContext();
816
+ return /* @__PURE__ */ jsxs("div", { ref, ...props, children: [
817
+ showPlayPause && /* @__PURE__ */ jsx(
818
+ PlayPauseOverlay,
819
+ {
820
+ isPlaying,
821
+ show: showPauseOverlay,
822
+ size: 64,
823
+ autoHideDelay: 800,
824
+ showOnStateChange: false
825
+ }
826
+ ),
827
+ showDoubleTapHeart && /* @__PURE__ */ jsx(
828
+ DoubleTapHeart,
829
+ {
830
+ show: showHeart,
831
+ position: heartPosition,
832
+ size: 100,
833
+ showParticles: true,
834
+ particleCount: 8
835
+ }
836
+ ),
837
+ showVideoInfo && /* @__PURE__ */ jsx(
838
+ VideoOverlay,
839
+ {
840
+ video,
841
+ onAuthorClick,
842
+ timelineExpanded
843
+ }
844
+ )
845
+ ] });
846
+ }
847
+ );
848
+ VideoFeedItemOverlay.displayName = "VideoFeedItemOverlay";
849
+ var VideoFeedItem = forwardRef(
850
+ ({
851
+ video,
852
+ isActive = false,
853
+ priority = "none",
854
+ showTimeline = true,
855
+ onLike,
856
+ onComment,
857
+ onShare,
858
+ onAuthorClick,
859
+ style,
860
+ className = "",
861
+ children
862
+ }, ref) => {
863
+ const state = useVideoFeedItemState({
864
+ video,
865
+ isActive,
866
+ priority,
867
+ onLike,
868
+ onComment,
869
+ onShare,
870
+ onAuthorClick
871
+ });
872
+ const content = children ?? /* @__PURE__ */ jsxs(Fragment, { children: [
873
+ /* @__PURE__ */ jsx(VideoFeedItemPlayer, {}),
874
+ /* @__PURE__ */ jsx(VideoFeedItemOverlay, {}),
875
+ /* @__PURE__ */ jsx(VideoFeedItemActions, {}),
876
+ showTimeline && /* @__PURE__ */ jsx(VideoFeedItemTimeline, {})
877
+ ] });
878
+ return /* @__PURE__ */ jsx(VideoFeedItemContext.Provider, { value: state, children: /* @__PURE__ */ jsx(
879
+ "div",
880
+ {
881
+ ref: (node) => {
882
+ if (typeof ref === "function") {
883
+ ref(node);
884
+ } else if (ref) {
885
+ ref.current = node;
886
+ }
887
+ state.containerRef.current = node;
888
+ },
889
+ style: mergeStyles(videoFeedItemStyles.container, style),
890
+ className,
891
+ ...state.gestureBindings(),
892
+ children: content
893
+ }
894
+ ) });
895
+ }
896
+ );
897
+ VideoFeedItem.displayName = "VideoFeedItem";
898
+ function getPreloadPriorityForFeed(index, currentIndex) {
899
+ const distance = Math.abs(index - currentIndex);
900
+ if (distance === 0) return 1;
901
+ if (distance === 1) return 3;
902
+ if (distance === 2) return 5;
903
+ if (distance === 3) return 7;
904
+ return 10;
905
+ }
906
+ function mapPriorityToNumeric(priority) {
907
+ switch (priority) {
908
+ case "high":
909
+ return 1;
910
+ case "medium":
911
+ return 3;
912
+ case "low":
913
+ return 5;
914
+ case "metadata":
915
+ return 7;
916
+ default:
917
+ return 10;
918
+ }
919
+ }
920
+ function getPreloadPriority(index, currentIndex) {
921
+ const distance = index - currentIndex;
922
+ if (distance === 0) return "high";
923
+ if (distance === -1) return "high";
924
+ if (distance === 1) return "high";
925
+ if (distance === 2) return "medium";
926
+ if (distance === 3) return "low";
927
+ if (Math.abs(distance) <= 5) return "metadata";
928
+ return "none";
929
+ }
930
+ var SPRING_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
931
+ var DEFAULT_DURATION = 300;
932
+ var DEFAULT_VIEWPORT_HEIGHT = 800;
933
+ var TRANSITION_END_BUFFER = 50;
934
+ function useSwipeAnimation({
935
+ trackRef,
936
+ transitionDuration = DEFAULT_DURATION,
937
+ easing = SPRING_EASING,
938
+ onTransitionEnd
939
+ }) {
940
+ const viewportHeightRef = useRef(
941
+ typeof window !== "undefined" ? window.innerHeight : DEFAULT_VIEWPORT_HEIGHT
942
+ );
943
+ const currentYRef = useRef(0);
944
+ const rafIdRef = useRef(null);
945
+ const isAnimatingRef = useRef(false);
946
+ const isMountedRef = useRef(true);
947
+ useEffect(() => {
948
+ const handleResize = () => {
949
+ viewportHeightRef.current = window.innerHeight;
950
+ };
951
+ const handleOrientationChange = () => {
952
+ setTimeout(() => {
953
+ viewportHeightRef.current = window.innerHeight;
954
+ }, 100);
955
+ };
956
+ window.addEventListener("resize", handleResize, { passive: true });
957
+ window.addEventListener("orientationchange", handleOrientationChange, { passive: true });
958
+ return () => {
959
+ window.removeEventListener("resize", handleResize);
960
+ window.removeEventListener("orientationchange", handleOrientationChange);
961
+ };
962
+ }, []);
963
+ const setTranslateY = useCallback((y) => {
964
+ currentYRef.current = y;
965
+ if (rafIdRef.current !== null) {
966
+ cancelAnimationFrame(rafIdRef.current);
967
+ }
968
+ rafIdRef.current = requestAnimationFrame(() => {
969
+ const track = trackRef.current;
970
+ if (track) {
971
+ track.style.transition = "none";
972
+ track.style.transform = `translateY(${y}px)`;
973
+ }
974
+ rafIdRef.current = null;
975
+ });
976
+ }, [trackRef]);
977
+ const animateTo = useCallback((targetY) => {
978
+ return new Promise((resolve) => {
979
+ const track = trackRef.current;
980
+ if (!track || !isMountedRef.current) {
981
+ resolve();
982
+ return;
983
+ }
984
+ if (isAnimatingRef.current) {
985
+ resolve();
986
+ return;
987
+ }
988
+ isAnimatingRef.current = true;
989
+ currentYRef.current = targetY;
990
+ let cleanup = null;
991
+ let fallbackTimeout = null;
992
+ const handleTransitionEnd = (e) => {
993
+ if (e.propertyName !== "transform") return;
994
+ cleanup?.();
995
+ if (isMountedRef.current) {
996
+ isAnimatingRef.current = false;
997
+ onTransitionEnd?.();
998
+ }
999
+ resolve();
1000
+ };
1001
+ cleanup = () => {
1002
+ track.removeEventListener("transitionend", handleTransitionEnd);
1003
+ if (fallbackTimeout) {
1004
+ clearTimeout(fallbackTimeout);
1005
+ fallbackTimeout = null;
1006
+ }
1007
+ };
1008
+ track.addEventListener("transitionend", handleTransitionEnd);
1009
+ fallbackTimeout = setTimeout(() => {
1010
+ cleanup?.();
1011
+ if (isMountedRef.current && isAnimatingRef.current) {
1012
+ isAnimatingRef.current = false;
1013
+ onTransitionEnd?.();
1014
+ }
1015
+ resolve();
1016
+ }, transitionDuration + TRANSITION_END_BUFFER);
1017
+ track.offsetHeight;
1018
+ track.style.transition = `transform ${transitionDuration}ms ${easing}`;
1019
+ track.style.transform = `translateY(${targetY}px)`;
1020
+ });
1021
+ }, [trackRef, transitionDuration, easing, onTransitionEnd]);
1022
+ const snapBack = useCallback(() => {
1023
+ return animateTo(0);
1024
+ }, [animateTo]);
1025
+ const getCurrentY = useCallback(() => {
1026
+ return currentYRef.current;
1027
+ }, []);
1028
+ useEffect(() => {
1029
+ isMountedRef.current = true;
1030
+ return () => {
1031
+ isMountedRef.current = false;
1032
+ if (rafIdRef.current !== null) {
1033
+ cancelAnimationFrame(rafIdRef.current);
1034
+ rafIdRef.current = null;
1035
+ }
1036
+ };
1037
+ }, []);
1038
+ return {
1039
+ setTranslateY,
1040
+ animateTo,
1041
+ snapBack,
1042
+ getCurrentY,
1043
+ viewportHeight: viewportHeightRef.current,
1044
+ isAnimating: isAnimatingRef.current
1045
+ };
1046
+ }
1047
+ var DEFAULT_TRANSITION_DURATION = 300;
1048
+ var DEFAULT_SWIPE_THRESHOLD = 50;
1049
+ var DEFAULT_VELOCITY_THRESHOLD = 0.3;
1050
+ var SPRING_EASING2 = "cubic-bezier(0.32, 0.72, 0, 1)";
1051
+ var feedStyles = {
1052
+ container: {
1053
+ position: "fixed",
1054
+ inset: 0,
1055
+ overflow: "hidden",
1056
+ backgroundColor: colors.background,
1057
+ touchAction: "none",
1058
+ // Prevent browser gestures, @use-gesture handles all
1059
+ userSelect: "none",
1060
+ WebkitUserSelect: "none"
1061
+ },
1062
+ track: {
1063
+ position: "relative",
1064
+ width: "100%",
1065
+ height: "100%",
1066
+ willChange: "transform"
1067
+ },
1068
+ slide: {
1069
+ position: "absolute",
1070
+ left: 0,
1071
+ width: "100%",
1072
+ height: "100%",
1073
+ backfaceVisibility: "hidden",
1074
+ WebkitBackfaceVisibility: "hidden"
1075
+ },
1076
+ loadingIndicator: {
1077
+ position: "absolute",
1078
+ bottom: 80,
1079
+ left: 0,
1080
+ right: 0,
1081
+ display: "flex",
1082
+ justifyContent: "center",
1083
+ zIndex: zIndices.base,
1084
+ pointerEvents: "none"
1085
+ },
1086
+ spinner: {
1087
+ width: 24,
1088
+ height: 24,
1089
+ borderWidth: 2,
1090
+ borderStyle: "solid",
1091
+ borderColor: "rgba(255, 255, 255, 0.3)",
1092
+ borderTopColor: colors.text,
1093
+ borderRadius: radii.full,
1094
+ animation: "xhub-reel-spin 1s linear infinite"
1095
+ }
1096
+ };
1097
+ var VideoFeed = forwardRef(
1098
+ ({
1099
+ videos,
1100
+ initialIndex = 0,
1101
+ onLoadMore,
1102
+ onVideoChange,
1103
+ onLike,
1104
+ onComment,
1105
+ onShare,
1106
+ onAuthorClick,
1107
+ isLoading = false,
1108
+ hasMore = false,
1109
+ loadMoreThreshold = 3,
1110
+ transitionDuration = DEFAULT_TRANSITION_DURATION,
1111
+ swipeThreshold = DEFAULT_SWIPE_THRESHOLD,
1112
+ velocityThreshold = DEFAULT_VELOCITY_THRESHOLD,
1113
+ gesturesDisabled = false,
1114
+ hapticEnabled = true,
1115
+ style,
1116
+ className = ""
1117
+ }, ref) => {
1118
+ const [currentIndex, setCurrentIndex] = useState(
1119
+ () => Math.min(Math.max(0, initialIndex), Math.max(0, videos.length - 1))
1120
+ );
1121
+ const [isLocked, setIsLocked] = useState(false);
1122
+ const [isAnimating, setIsAnimating] = useState(false);
1123
+ const containerRef = useRef(null);
1124
+ const trackRef = useRef(null);
1125
+ const videosRef = useRef(videos);
1126
+ const currentIndexRef = useRef(currentIndex);
1127
+ const isLoadingRef = useRef(isLoading);
1128
+ const hasMoreRef = useRef(hasMore);
1129
+ const isLockedRef = useRef(isLocked);
1130
+ const { setCurrentIndex: storeSetCurrentIndex } = useFeedStore();
1131
+ const {
1132
+ setTranslateY,
1133
+ // Direct DOM manipulation, RAF batched
1134
+ animateTo,
1135
+ // CSS transition with transitionend
1136
+ snapBack,
1137
+ // Animate back to 0
1138
+ viewportHeight
1139
+ // Cached, no layout thrashing
1140
+ } = useSwipeAnimation({ trackRef, transitionDuration, easing: SPRING_EASING2 });
1141
+ useEffect(() => {
1142
+ videosRef.current = videos;
1143
+ }, [videos]);
1144
+ useEffect(() => {
1145
+ currentIndexRef.current = currentIndex;
1146
+ }, [currentIndex]);
1147
+ useEffect(() => {
1148
+ isLoadingRef.current = isLoading;
1149
+ }, [isLoading]);
1150
+ useEffect(() => {
1151
+ hasMoreRef.current = hasMore;
1152
+ }, [hasMore]);
1153
+ useEffect(() => {
1154
+ isLockedRef.current = isLocked;
1155
+ }, [isLocked]);
1156
+ useEffect(() => {
1157
+ if (videos.length === 0) {
1158
+ setCurrentIndex(0);
1159
+ return;
1160
+ }
1161
+ if (currentIndex >= videos.length) {
1162
+ const newIndex = videos.length - 1;
1163
+ const video = videos[newIndex];
1164
+ setCurrentIndex(newIndex);
1165
+ storeSetCurrentIndex(newIndex);
1166
+ if (video) {
1167
+ onVideoChange?.(video, newIndex);
1168
+ }
1169
+ }
1170
+ }, [videos.length, currentIndex, storeSetCurrentIndex, onVideoChange, videos]);
1171
+ const getVideoPriority = useCallback((index) => {
1172
+ return getPreloadPriority(index, currentIndex);
1173
+ }, [currentIndex]);
1174
+ const checkLoadMore = useCallback((index) => {
1175
+ const vids = videosRef.current;
1176
+ if (hasMoreRef.current && !isLoadingRef.current && vids.length - index <= loadMoreThreshold) {
1177
+ onLoadMore?.();
1178
+ }
1179
+ }, [loadMoreThreshold, onLoadMore]);
1180
+ const slideTo = useCallback(async (index, animated = true) => {
1181
+ if (isLockedRef.current) return;
1182
+ const vids = videosRef.current;
1183
+ const clampedIndex = Math.max(0, Math.min(index, vids.length - 1));
1184
+ const currentIdx = currentIndexRef.current;
1185
+ if (clampedIndex === currentIdx) {
1186
+ setTranslateY(0);
1187
+ return;
1188
+ }
1189
+ const direction = clampedIndex > currentIdx ? -1 : 1;
1190
+ if (animated) {
1191
+ setIsLocked(true);
1192
+ setIsAnimating(true);
1193
+ await animateTo(direction * viewportHeight);
1194
+ setCurrentIndex(clampedIndex);
1195
+ storeSetCurrentIndex(clampedIndex);
1196
+ setTranslateY(0);
1197
+ const video = vids[clampedIndex];
1198
+ if (video) {
1199
+ onVideoChange?.(video, clampedIndex);
1200
+ }
1201
+ checkLoadMore(clampedIndex);
1202
+ setIsAnimating(false);
1203
+ setIsLocked(false);
1204
+ } else {
1205
+ setCurrentIndex(clampedIndex);
1206
+ storeSetCurrentIndex(clampedIndex);
1207
+ setTranslateY(0);
1208
+ const video = vids[clampedIndex];
1209
+ if (video) {
1210
+ onVideoChange?.(video, clampedIndex);
1211
+ checkLoadMore(clampedIndex);
1212
+ }
1213
+ }
1214
+ }, [viewportHeight, animateTo, setTranslateY, storeSetCurrentIndex, onVideoChange, checkLoadMore]);
1215
+ const slideNext = useCallback((animated = true) => {
1216
+ const vids = videosRef.current;
1217
+ const idx = currentIndexRef.current;
1218
+ if (idx < vids.length - 1) {
1219
+ slideTo(idx + 1, animated);
1220
+ }
1221
+ }, [slideTo]);
1222
+ const slidePrev = useCallback((animated = true) => {
1223
+ const idx = currentIndexRef.current;
1224
+ if (idx > 0) {
1225
+ slideTo(idx - 1, animated);
1226
+ }
1227
+ }, [slideTo]);
1228
+ const handleSwipeProgress = useCallback(
1229
+ (_progress, direction, movement) => {
1230
+ const vids = videosRef.current;
1231
+ const idx = currentIndexRef.current;
1232
+ const canGoUp = idx > 0;
1233
+ const canGoDown = idx < vids.length - 1;
1234
+ let delta = movement;
1235
+ const atBoundary = direction === "down" && !canGoUp || direction === "up" && !canGoDown;
1236
+ if (atBoundary) {
1237
+ delta *= 0.3;
1238
+ }
1239
+ setTranslateY(delta);
1240
+ },
1241
+ [setTranslateY]
1242
+ );
1243
+ const handleSwipeUp = useCallback(async () => {
1244
+ const vids = videosRef.current;
1245
+ const idx = currentIndexRef.current;
1246
+ const canGoDown = idx < vids.length - 1;
1247
+ if (!canGoDown) {
1248
+ await snapBack();
1249
+ return;
1250
+ }
1251
+ setIsLocked(true);
1252
+ setIsAnimating(true);
1253
+ await animateTo(-viewportHeight);
1254
+ const newIndex = currentIndexRef.current + 1;
1255
+ const newVids = videosRef.current;
1256
+ if (newIndex < newVids.length) {
1257
+ setCurrentIndex(newIndex);
1258
+ storeSetCurrentIndex(newIndex);
1259
+ const video = newVids[newIndex];
1260
+ if (video) {
1261
+ onVideoChange?.(video, newIndex);
1262
+ }
1263
+ checkLoadMore(newIndex);
1264
+ }
1265
+ setTranslateY(0);
1266
+ setIsAnimating(false);
1267
+ setIsLocked(false);
1268
+ }, [viewportHeight, animateTo, snapBack, setTranslateY, storeSetCurrentIndex, onVideoChange, checkLoadMore]);
1269
+ const handleSwipeDown = useCallback(async () => {
1270
+ const idx = currentIndexRef.current;
1271
+ const canGoUp = idx > 0;
1272
+ if (!canGoUp) {
1273
+ await snapBack();
1274
+ return;
1275
+ }
1276
+ setIsLocked(true);
1277
+ setIsAnimating(true);
1278
+ await animateTo(viewportHeight);
1279
+ const newIndex = currentIndexRef.current - 1;
1280
+ if (newIndex >= 0) {
1281
+ setCurrentIndex(newIndex);
1282
+ storeSetCurrentIndex(newIndex);
1283
+ const video = videosRef.current[newIndex];
1284
+ if (video) {
1285
+ onVideoChange?.(video, newIndex);
1286
+ }
1287
+ }
1288
+ setTranslateY(0);
1289
+ setIsAnimating(false);
1290
+ setIsLocked(false);
1291
+ }, [viewportHeight, animateTo, snapBack, setTranslateY, storeSetCurrentIndex, onVideoChange]);
1292
+ const handleSwipeCancel = useCallback(async () => {
1293
+ await snapBack();
1294
+ }, [snapBack]);
1295
+ const { bind: swipeBind } = useVerticalSwipe({
1296
+ onSwipeUp: handleSwipeUp,
1297
+ onSwipeDown: handleSwipeDown,
1298
+ onSwipeProgress: handleSwipeProgress,
1299
+ onSwipeCancel: handleSwipeCancel,
1300
+ threshold: swipeThreshold / viewportHeight,
1301
+ // Convert px to percentage
1302
+ velocityThreshold,
1303
+ hapticEnabled,
1304
+ disabled: gesturesDisabled || isLocked,
1305
+ enableProgressState: false
1306
+ // Disable for maximum performance
1307
+ });
1308
+ useImperativeHandle(ref, () => ({
1309
+ slideTo,
1310
+ slideNext,
1311
+ slidePrev,
1312
+ get activeIndex() {
1313
+ return currentIndexRef.current;
1314
+ },
1315
+ get totalSlides() {
1316
+ return videosRef.current.length;
1317
+ },
1318
+ get isBeginning() {
1319
+ return currentIndexRef.current === 0;
1320
+ },
1321
+ get isEnd() {
1322
+ return currentIndexRef.current === videosRef.current.length - 1;
1323
+ }
1324
+ }));
1325
+ useEffect(() => {
1326
+ const video = videos[currentIndex];
1327
+ if (video) {
1328
+ onVideoChange?.(video, currentIndex);
1329
+ }
1330
+ }, []);
1331
+ const slides = [];
1332
+ if (currentIndex > 0) {
1333
+ slides.push({ index: currentIndex - 1, position: -1 });
1334
+ }
1335
+ slides.push({ index: currentIndex, position: 0 });
1336
+ if (currentIndex < videos.length - 1) {
1337
+ slides.push({ index: currentIndex + 1, position: 1 });
1338
+ }
1339
+ if (videos.length === 0) {
1340
+ return /* @__PURE__ */ jsx(
1341
+ "div",
1342
+ {
1343
+ ref: containerRef,
1344
+ style: mergeStyles(feedStyles.container, style),
1345
+ className,
1346
+ "data-xhub-reel-feed": true,
1347
+ children: isLoading && /* @__PURE__ */ jsx("div", { style: { ...feedStyles.loadingIndicator, top: "50%", bottom: "auto" }, children: /* @__PURE__ */ jsx("div", { style: feedStyles.spinner }) })
1348
+ }
1349
+ );
1350
+ }
1351
+ const trackStyle = {
1352
+ ...feedStyles.track
1353
+ // transform and transition are now managed via trackRef.current.style
1354
+ };
1355
+ return /* @__PURE__ */ jsxs(
1356
+ "div",
1357
+ {
1358
+ ref: containerRef,
1359
+ ...swipeBind(),
1360
+ style: mergeStyles(feedStyles.container, style),
1361
+ className,
1362
+ "data-xhub-reel-feed": true,
1363
+ children: [
1364
+ /* @__PURE__ */ jsx("style", { children: `
1365
+ @keyframes xhub-reel-spin {
1366
+ to { transform: rotate(360deg); }
1367
+ }
1368
+ ` }),
1369
+ /* @__PURE__ */ jsx("div", { ref: trackRef, style: trackStyle, children: slides.map(({ index, position }) => {
1370
+ const video = videos[index];
1371
+ if (!video) return null;
1372
+ const priority = getVideoPriority(index);
1373
+ const isActive = index === currentIndex && !isAnimating;
1374
+ return /* @__PURE__ */ jsx(
1375
+ "div",
1376
+ {
1377
+ "data-index": index,
1378
+ style: {
1379
+ ...feedStyles.slide,
1380
+ top: position * viewportHeight
1381
+ },
1382
+ children: /* @__PURE__ */ jsx(
1383
+ VideoFeedItem,
1384
+ {
1385
+ video,
1386
+ isActive,
1387
+ priority,
1388
+ onLike: () => onLike?.(video),
1389
+ onComment: () => onComment?.(video),
1390
+ onShare: () => onShare?.(video),
1391
+ onAuthorClick: () => onAuthorClick?.(video)
1392
+ }
1393
+ )
1394
+ },
1395
+ video.id
1396
+ );
1397
+ }) }),
1398
+ isLoading && /* @__PURE__ */ jsx("div", { style: feedStyles.loadingIndicator, children: /* @__PURE__ */ jsx("div", { style: feedStyles.spinner }) })
1399
+ ]
1400
+ }
1401
+ );
1402
+ }
1403
+ );
1404
+ VideoFeed.displayName = "VideoFeed";
1405
+ function useVideoFeed(options = {}) {
1406
+ const {
1407
+ config,
1408
+ enabled = true,
1409
+ initialVideos = [],
1410
+ staleTime = 1e3 * 60 * 5,
1411
+ // 5 minutes
1412
+ refetchOnWindowFocus = false,
1413
+ onSuccess,
1414
+ onError,
1415
+ userId,
1416
+ tag,
1417
+ searchQuery,
1418
+ limit = 10,
1419
+ cursor
1420
+ } = options;
1421
+ void useQueryClient();
1422
+ const apiClientRef = useRef(null);
1423
+ const configKey = useMemo(() => {
1424
+ if (!config) return null;
1425
+ return JSON.stringify({
1426
+ baseUrl: config.baseUrl,
1427
+ apiKey: config.apiKey,
1428
+ auth: config.auth?.accessToken,
1429
+ endpoints: config.endpoints
1430
+ });
1431
+ }, [config]);
1432
+ useEffect(() => {
1433
+ if (config && configKey) {
1434
+ apiClientRef.current = createXHubReelApiClient(config);
1435
+ } else {
1436
+ apiClientRef.current = null;
1437
+ }
1438
+ }, [configKey]);
1439
+ const fetchParams = useMemo(
1440
+ () => ({
1441
+ userId,
1442
+ tag,
1443
+ searchQuery,
1444
+ limit,
1445
+ cursor
1446
+ }),
1447
+ [userId, tag, searchQuery, limit, cursor]
1448
+ );
1449
+ const queryKey = useMemo(
1450
+ () => queryKeys.videos.list(fetchParams),
1451
+ [fetchParams]
1452
+ );
1453
+ const fetchVideos = useCallback(
1454
+ async ({ pageParam }) => {
1455
+ if (!apiClientRef.current) {
1456
+ throw new Error("[XHubReel] API client not initialized. Provide config to useVideoFeed.");
1457
+ }
1458
+ return apiClientRef.current.fetchVideos({
1459
+ ...fetchParams,
1460
+ cursor: pageParam
1461
+ });
1462
+ },
1463
+ [fetchParams]
1464
+ );
1465
+ const {
1466
+ data,
1467
+ isLoading,
1468
+ isFetchingNextPage,
1469
+ hasNextPage,
1470
+ fetchNextPage: tanstackFetchNextPage,
1471
+ refetch: tanstackRefetch,
1472
+ error
1473
+ } = useInfiniteQuery({
1474
+ queryKey,
1475
+ queryFn: fetchVideos,
1476
+ getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextCursor : void 0,
1477
+ initialPageParam: void 0,
1478
+ enabled: enabled && !!config,
1479
+ staleTime,
1480
+ refetchOnWindowFocus
1481
+ });
1482
+ useEffect(() => {
1483
+ if (data && onSuccess) {
1484
+ const allVideos = data.pages.flatMap((page) => page.videos);
1485
+ onSuccess(allVideos);
1486
+ }
1487
+ }, [data, onSuccess]);
1488
+ useEffect(() => {
1489
+ if (error && onError) {
1490
+ onError(error);
1491
+ }
1492
+ }, [error, onError]);
1493
+ const videos = useMemo(() => {
1494
+ if (!data) {
1495
+ return initialVideos;
1496
+ }
1497
+ return data.pages.flatMap((page) => page.videos);
1498
+ }, [data, initialVideos]);
1499
+ const totalCount = useMemo(() => {
1500
+ if (!data || data.pages.length === 0) return void 0;
1501
+ const firstPage = data.pages[0];
1502
+ return firstPage?.total;
1503
+ }, [data]);
1504
+ const fetchNextPage = useCallback(async () => {
1505
+ if (!hasNextPage || isFetchingNextPage) return;
1506
+ try {
1507
+ await tanstackFetchNextPage();
1508
+ } catch (err) {
1509
+ console.error("[XHubReel] Error fetching next page:", err);
1510
+ onError?.(err);
1511
+ }
1512
+ }, [hasNextPage, isFetchingNextPage, tanstackFetchNextPage, onError]);
1513
+ const refetch = useCallback(async () => {
1514
+ try {
1515
+ await tanstackRefetch();
1516
+ } catch (err) {
1517
+ console.error("[XHubReel] Error refetching:", err);
1518
+ onError?.(err);
1519
+ }
1520
+ }, [tanstackRefetch, onError]);
1521
+ return {
1522
+ videos,
1523
+ isLoading: isLoading && !!config,
1524
+ isFetchingMore: isFetchingNextPage,
1525
+ hasMore: hasNextPage ?? false,
1526
+ fetchNextPage,
1527
+ refetch,
1528
+ error: error ?? null,
1529
+ isApiMode: !!config,
1530
+ totalCount
1531
+ };
1532
+ }
1533
+ async function prefetchVideoFeed(queryClient, config, params = {}) {
1534
+ const client = createXHubReelApiClient(config);
1535
+ await queryClient.prefetchInfiniteQuery({
1536
+ queryKey: queryKeys.videos.list(params),
1537
+ queryFn: ({ pageParam }) => client.fetchVideos({ ...params, cursor: pageParam }),
1538
+ initialPageParam: void 0
1539
+ });
1540
+ }
1541
+ var stateStyles = {
1542
+ container: {
1543
+ position: "fixed",
1544
+ inset: 0,
1545
+ display: "flex",
1546
+ flexDirection: "column",
1547
+ alignItems: "center",
1548
+ justifyContent: "center",
1549
+ backgroundColor: colors.background,
1550
+ color: colors.text,
1551
+ gap: spacing[4]
1552
+ },
1553
+ spinner: {
1554
+ width: 40,
1555
+ height: 40,
1556
+ borderWidth: 3,
1557
+ borderStyle: "solid",
1558
+ borderColor: "rgba(255, 255, 255, 0.2)",
1559
+ borderTopColor: colors.accent,
1560
+ borderRadius: radii.full,
1561
+ animation: "xhub-reel-spin 1s linear infinite"
1562
+ },
1563
+ text: {
1564
+ fontSize: fontSizes.md,
1565
+ color: colors.textSecondary,
1566
+ textAlign: "center",
1567
+ maxWidth: 280,
1568
+ lineHeight: 1.5
1569
+ },
1570
+ button: {
1571
+ padding: `${spacing[3]}px ${spacing[6]}px`,
1572
+ backgroundColor: colors.accent,
1573
+ color: colors.text,
1574
+ border: "none",
1575
+ borderRadius: radii.md,
1576
+ fontSize: fontSizes.sm,
1577
+ fontWeight: fontWeights.semibold,
1578
+ cursor: "pointer"
1579
+ },
1580
+ errorIcon: {
1581
+ fontSize: 48,
1582
+ marginBottom: spacing[2]
1583
+ }
1584
+ };
1585
+ var DefaultLoading = () => /* @__PURE__ */ jsxs("div", { style: stateStyles.container, children: [
1586
+ /* @__PURE__ */ jsx("style", { children: `
1587
+ @keyframes xhub-reel-spin {
1588
+ to { transform: rotate(360deg); }
1589
+ }
1590
+ ` }),
1591
+ /* @__PURE__ */ jsx("div", { style: stateStyles.spinner }),
1592
+ /* @__PURE__ */ jsx("p", { style: stateStyles.text, children: "\u0110ang t\u1EA3i video..." })
1593
+ ] });
1594
+ var DefaultError = ({ error, retry }) => /* @__PURE__ */ jsxs("div", { style: stateStyles.container, children: [
1595
+ /* @__PURE__ */ jsx("div", { style: stateStyles.errorIcon, children: "\u{1F615}" }),
1596
+ /* @__PURE__ */ jsx("p", { style: stateStyles.text, children: error.message || "C\xF3 l\u1ED7i x\u1EA3y ra khi t\u1EA3i video" }),
1597
+ /* @__PURE__ */ jsx("button", { style: stateStyles.button, onClick: retry, children: "Th\u1EED l\u1EA1i" })
1598
+ ] });
1599
+ var DefaultEmpty = () => /* @__PURE__ */ jsxs("div", { style: stateStyles.container, children: [
1600
+ /* @__PURE__ */ jsx("div", { style: stateStyles.errorIcon, children: "\u{1F4ED}" }),
1601
+ /* @__PURE__ */ jsx("p", { style: stateStyles.text, children: "Kh\xF4ng c\xF3 video n\xE0o \u0111\u1EC3 hi\u1EC3n th\u1ECB" })
1602
+ ] });
1603
+ var ConnectedVideoFeed = forwardRef(
1604
+ ({
1605
+ config: configProp,
1606
+ userId,
1607
+ tag,
1608
+ searchQuery,
1609
+ pageSize = 10,
1610
+ initialVideos,
1611
+ onFetchSuccess,
1612
+ onFetchError,
1613
+ renderLoading = () => /* @__PURE__ */ jsx(DefaultLoading, {}),
1614
+ renderError = (error, retry) => /* @__PURE__ */ jsx(DefaultError, { error, retry }),
1615
+ renderEmpty = () => /* @__PURE__ */ jsx(DefaultEmpty, {}),
1616
+ // Pass through VideoFeed props
1617
+ onVideoChange,
1618
+ onLike,
1619
+ onComment,
1620
+ onShare,
1621
+ onAuthorClick,
1622
+ ...videoFeedProps
1623
+ }, ref) => {
1624
+ const { config: contextConfig } = useXHubReelConfig();
1625
+ const config = configProp || contextConfig;
1626
+ const {
1627
+ videos,
1628
+ isLoading,
1629
+ isFetchingMore,
1630
+ hasMore,
1631
+ fetchNextPage,
1632
+ refetch,
1633
+ error
1634
+ } = useVideoFeed({
1635
+ config: config || void 0,
1636
+ userId,
1637
+ tag,
1638
+ searchQuery,
1639
+ limit: pageSize,
1640
+ initialVideos,
1641
+ onSuccess: onFetchSuccess,
1642
+ onError: onFetchError
1643
+ });
1644
+ const handleLoadMore = useCallback(async () => {
1645
+ await fetchNextPage();
1646
+ }, [fetchNextPage]);
1647
+ const handleRetry = useCallback(() => {
1648
+ refetch();
1649
+ }, [refetch]);
1650
+ if (!config) {
1651
+ return /* @__PURE__ */ jsxs("div", { style: stateStyles.container, children: [
1652
+ /* @__PURE__ */ jsx("div", { style: stateStyles.errorIcon, children: "\u26A0\uFE0F" }),
1653
+ /* @__PURE__ */ jsx("p", { style: stateStyles.text, children: "Ch\u01B0a c\u1EA5u h\xECnh API. Vui l\xF2ng wrap component trong XHubReelProvider v\u1EDBi config ho\u1EB7c truy\u1EC1n config prop." })
1654
+ ] });
1655
+ }
1656
+ if (isLoading && videos.length === 0) {
1657
+ return renderLoading();
1658
+ }
1659
+ if (error && videos.length === 0) {
1660
+ return renderError(error, handleRetry);
1661
+ }
1662
+ if (!isLoading && videos.length === 0) {
1663
+ return renderEmpty();
1664
+ }
1665
+ return /* @__PURE__ */ jsx(
1666
+ VideoFeed,
1667
+ {
1668
+ ref,
1669
+ videos,
1670
+ isLoading: isFetchingMore,
1671
+ hasMore,
1672
+ onLoadMore: handleLoadMore,
1673
+ onVideoChange,
1674
+ onLike,
1675
+ onComment,
1676
+ onShare,
1677
+ onAuthorClick,
1678
+ ...videoFeedProps
1679
+ }
1680
+ );
1681
+ }
1682
+ );
1683
+ ConnectedVideoFeed.displayName = "ConnectedVideoFeed";
1684
+ function useFeedScroll({
1685
+ scrollRef,
1686
+ itemCount,
1687
+ itemHeight,
1688
+ onScrollChange,
1689
+ onIndexChange
1690
+ }) {
1691
+ const [currentIndex, setCurrentIndex] = useState(0);
1692
+ const [scrollVelocity, setScrollVelocity] = useState(0);
1693
+ const [isScrolling, setIsScrolling] = useState(false);
1694
+ const lastScrollTopRef = useRef(0);
1695
+ const lastScrollTimeRef = useRef(Date.now());
1696
+ const scrollTimeoutRef = useRef(null);
1697
+ const getItemHeight = useCallback(() => {
1698
+ if (itemHeight) return itemHeight;
1699
+ if (typeof window !== "undefined") return window.innerHeight;
1700
+ return 800;
1701
+ }, [itemHeight]);
1702
+ const scrollToIndex = useCallback(
1703
+ (index, smooth = true) => {
1704
+ const element = scrollRef.current;
1705
+ if (!element) return;
1706
+ const clampedIndex = Math.max(0, Math.min(index, itemCount - 1));
1707
+ const top = clampedIndex * getItemHeight();
1708
+ element.scrollTo({
1709
+ top,
1710
+ behavior: smooth ? "smooth" : "auto"
1711
+ });
1712
+ },
1713
+ [scrollRef, itemCount, getItemHeight]
1714
+ );
1715
+ const scrollToNext = useCallback(() => {
1716
+ scrollToIndex(currentIndex + 1);
1717
+ }, [currentIndex, scrollToIndex]);
1718
+ const scrollToPrev = useCallback(() => {
1719
+ scrollToIndex(currentIndex - 1);
1720
+ }, [currentIndex, scrollToIndex]);
1721
+ useEffect(() => {
1722
+ const element = scrollRef.current;
1723
+ if (!element) return;
1724
+ const handleScroll = () => {
1725
+ const now = Date.now();
1726
+ const scrollTop = element.scrollTop;
1727
+ const timeDelta = now - lastScrollTimeRef.current;
1728
+ const scrollDelta = Math.abs(scrollTop - lastScrollTopRef.current);
1729
+ if (timeDelta > 0) {
1730
+ const velocity = scrollDelta / timeDelta * 1e3;
1731
+ setScrollVelocity(velocity);
1732
+ onScrollChange?.(scrollTop, velocity);
1733
+ }
1734
+ const height = getItemHeight();
1735
+ const newIndex = Math.round(scrollTop / height);
1736
+ if (newIndex !== currentIndex && newIndex >= 0 && newIndex < itemCount) {
1737
+ setCurrentIndex(newIndex);
1738
+ onIndexChange?.(newIndex);
1739
+ }
1740
+ setIsScrolling(true);
1741
+ if (scrollTimeoutRef.current) {
1742
+ clearTimeout(scrollTimeoutRef.current);
1743
+ }
1744
+ scrollTimeoutRef.current = setTimeout(() => {
1745
+ setIsScrolling(false);
1746
+ setScrollVelocity(0);
1747
+ }, 150);
1748
+ lastScrollTopRef.current = scrollTop;
1749
+ lastScrollTimeRef.current = now;
1750
+ };
1751
+ element.addEventListener("scroll", handleScroll, { passive: true });
1752
+ return () => {
1753
+ element.removeEventListener("scroll", handleScroll);
1754
+ if (scrollTimeoutRef.current) {
1755
+ clearTimeout(scrollTimeoutRef.current);
1756
+ }
1757
+ };
1758
+ }, [scrollRef, itemCount, currentIndex, getItemHeight, onScrollChange, onIndexChange]);
1759
+ return {
1760
+ currentIndex,
1761
+ scrollVelocity,
1762
+ isScrolling,
1763
+ scrollToIndex,
1764
+ scrollToNext,
1765
+ scrollToPrev
1766
+ };
1767
+ }
1768
+ function useInfiniteScroll({
1769
+ onLoadMore,
1770
+ isLoading = false,
1771
+ hasMore = true,
1772
+ threshold: _threshold = 3,
1773
+ rootMargin = "0px 0px 200px 0px"
1774
+ }) {
1775
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
1776
+ const observerRef = useRef(null);
1777
+ const sentinelRef = useRef(null);
1778
+ const loadingRef = useRef(false);
1779
+ const handleLoadMore = useCallback(async () => {
1780
+ if (loadingRef.current || isLoading || !hasMore || !onLoadMore) return;
1781
+ loadingRef.current = true;
1782
+ setIsLoadingMore(true);
1783
+ try {
1784
+ await onLoadMore();
1785
+ } finally {
1786
+ loadingRef.current = false;
1787
+ setIsLoadingMore(false);
1788
+ }
1789
+ }, [onLoadMore, isLoading, hasMore]);
1790
+ const setSentinelRef = useCallback(
1791
+ (element) => {
1792
+ if (observerRef.current) {
1793
+ observerRef.current.disconnect();
1794
+ }
1795
+ sentinelRef.current = element;
1796
+ if (!element || !hasMore) return;
1797
+ observerRef.current = new IntersectionObserver(
1798
+ (entries) => {
1799
+ const [entry] = entries;
1800
+ if (entry?.isIntersecting) {
1801
+ handleLoadMore();
1802
+ }
1803
+ },
1804
+ {
1805
+ rootMargin,
1806
+ threshold: 0
1807
+ }
1808
+ );
1809
+ observerRef.current.observe(element);
1810
+ },
1811
+ [hasMore, rootMargin, handleLoadMore]
1812
+ );
1813
+ useEffect(() => {
1814
+ return () => {
1815
+ if (observerRef.current) {
1816
+ observerRef.current.disconnect();
1817
+ }
1818
+ };
1819
+ }, []);
1820
+ useEffect(() => {
1821
+ loadingRef.current = isLoading;
1822
+ }, [isLoading]);
1823
+ return {
1824
+ sentinelRef: setSentinelRef,
1825
+ isLoadingMore
1826
+ };
1827
+ }
1828
+
1829
+ export { ConnectedVideoFeed, VideoFeed, VideoFeedItem, VideoFeedItemActions, VideoFeedItemOverlay, VideoFeedItemPlayer, VideoFeedItemTimeline, VideoOverlay, getPreloadPriority, getPreloadPriorityForFeed, mapPriorityToNumeric, memoryManager, prefetchVideoFeed, useFeedScroll, useGlobalMemoryState, useInfiniteScroll, useMemoryManager, useSwipeAnimation, useVideoActivation, useVideoFeed, useVideoFeedItemContext, useVideoVisibility };