@xhub-reels/sdk 0.1.7

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.js ADDED
@@ -0,0 +1,2845 @@
1
+ import { createStore } from 'zustand/vanilla';
2
+ import { createContext, useRef, useEffect, useCallback, useMemo, useContext, useSyncExternalStore, useState } from 'react';
3
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
+ import Hls from 'hls.js';
5
+
6
+ // src/types/content.ts
7
+ function isVideoItem(item) {
8
+ return item.type === "video";
9
+ }
10
+ function isArticle(item) {
11
+ return item.type === "article";
12
+ }
13
+
14
+ // src/types/feed.ts
15
+ var DEFAULT_FEED_CONFIG = {
16
+ pageSize: 10,
17
+ maxRetries: 3,
18
+ retryDelay: 1e3,
19
+ maxCacheSize: 50,
20
+ staleTTL: 5 * 60 * 1e3
21
+ };
22
+
23
+ // src/types/player.ts
24
+ var PlayerStatus = /* @__PURE__ */ ((PlayerStatus2) => {
25
+ PlayerStatus2["IDLE"] = "idle";
26
+ PlayerStatus2["LOADING"] = "loading";
27
+ PlayerStatus2["PLAYING"] = "playing";
28
+ PlayerStatus2["PAUSED"] = "paused";
29
+ PlayerStatus2["BUFFERING"] = "buffering";
30
+ PlayerStatus2["ERROR"] = "error";
31
+ return PlayerStatus2;
32
+ })(PlayerStatus || {});
33
+ var DEFAULT_PLAYER_CONFIG = {
34
+ defaultVolume: 1,
35
+ defaultMuted: true,
36
+ circuitBreakerThreshold: 3,
37
+ circuitBreakerResetMs: 1e4
38
+ };
39
+
40
+ // src/domain/state-machine.ts
41
+ var VALID_TRANSITIONS = {
42
+ ["idle" /* IDLE */]: ["loading" /* LOADING */],
43
+ ["loading" /* LOADING */]: ["playing" /* PLAYING */, "error" /* ERROR */, "idle" /* IDLE */],
44
+ ["playing" /* PLAYING */]: [
45
+ "paused" /* PAUSED */,
46
+ "buffering" /* BUFFERING */,
47
+ "error" /* ERROR */,
48
+ "idle" /* IDLE */
49
+ ],
50
+ ["paused" /* PAUSED */]: ["playing" /* PLAYING */, "idle" /* IDLE */, "loading" /* LOADING */],
51
+ ["buffering" /* BUFFERING */]: ["playing" /* PLAYING */, "error" /* ERROR */, "idle" /* IDLE */],
52
+ ["error" /* ERROR */]: ["idle" /* IDLE */, "loading" /* LOADING */]
53
+ };
54
+ function isValidTransition(from, to) {
55
+ if (from === to) return true;
56
+ return VALID_TRANSITIONS[from].includes(to);
57
+ }
58
+ function canPlay(status) {
59
+ return status === "paused" /* PAUSED */ || status === "loading" /* LOADING */;
60
+ }
61
+ function canPause(status) {
62
+ return status === "playing" /* PLAYING */ || status === "buffering" /* BUFFERING */;
63
+ }
64
+ function canSeek(status) {
65
+ return status === "playing" /* PLAYING */ || status === "paused" /* PAUSED */ || status === "buffering" /* BUFFERING */;
66
+ }
67
+
68
+ // src/domain/PlayerEngine.ts
69
+ function createInitialCircuit() {
70
+ return {
71
+ state: "closed" /* CLOSED */,
72
+ consecutiveErrors: 0,
73
+ openedAt: null,
74
+ lastError: null
75
+ };
76
+ }
77
+ function createInitialState(muted, volume) {
78
+ return {
79
+ status: "idle" /* IDLE */,
80
+ currentVideo: null,
81
+ currentVideoId: null,
82
+ currentTime: 0,
83
+ duration: 0,
84
+ buffered: 0,
85
+ volume,
86
+ muted,
87
+ playbackRate: 1,
88
+ loopCount: 0,
89
+ watchTime: 0,
90
+ error: null,
91
+ ended: false,
92
+ pendingRestoreTime: null,
93
+ pendingRestoreVideoId: null
94
+ };
95
+ }
96
+ var PlayerEngine = class {
97
+ constructor(config = {}, analytics, logger) {
98
+ this.listeners = /* @__PURE__ */ new Set();
99
+ this.circuit = createInitialCircuit();
100
+ this.circuitResetTimer = null;
101
+ this.watchTimeInterval = null;
102
+ this.lastStatus = "idle" /* IDLE */;
103
+ this.config = { ...DEFAULT_PLAYER_CONFIG, ...config };
104
+ this.analytics = analytics;
105
+ this.logger = logger;
106
+ this.store = createStore(
107
+ () => createInitialState(this.config.defaultMuted, this.config.defaultVolume)
108
+ );
109
+ this.store.subscribe((state) => {
110
+ if (state.status !== this.lastStatus) {
111
+ this.emit({
112
+ type: "statusChange",
113
+ status: state.status,
114
+ previousStatus: this.lastStatus
115
+ });
116
+ this.lastStatus = state.status;
117
+ }
118
+ });
119
+ }
120
+ // ═══════════════════════════════════════════
121
+ // PUBLIC API — Playback Control
122
+ // ═══════════════════════════════════════════
123
+ /**
124
+ * Load a video and prepare for playback.
125
+ * Returns false if rejected by circuit breaker or invalid state.
126
+ */
127
+ load(video) {
128
+ if (!this.checkCircuit()) {
129
+ this.logger?.warn("[PlayerEngine] Load rejected \u2014 circuit OPEN");
130
+ this.emit({ type: "loadRejected", reason: "circuit_open" });
131
+ if (this.circuit.lastError) {
132
+ this.store.setState({ error: this.circuit.lastError });
133
+ this.transition("error" /* ERROR */);
134
+ }
135
+ return false;
136
+ }
137
+ const { status, currentVideo } = this.store.getState();
138
+ if (currentVideo && currentVideo.id !== video.id) {
139
+ this.trackLeave(this.store.getState());
140
+ }
141
+ if (status !== "idle" /* IDLE */ && status !== "error" /* ERROR */ && status !== "paused" /* PAUSED */) {
142
+ if (!this.transition("idle" /* IDLE */)) return false;
143
+ }
144
+ if (!this.transition("loading" /* LOADING */)) return false;
145
+ this.store.setState({
146
+ currentVideo: video,
147
+ currentVideoId: video.id,
148
+ currentTime: 0,
149
+ duration: video.duration,
150
+ loopCount: 0,
151
+ watchTime: 0,
152
+ error: null,
153
+ ended: false,
154
+ // Persist playback speed across videos
155
+ playbackRate: this.store.getState().playbackRate
156
+ });
157
+ this.emit({ type: "videoChange", video });
158
+ this.logger?.debug(`[PlayerEngine] Loaded: ${video.id}`);
159
+ return true;
160
+ }
161
+ play() {
162
+ const { status } = this.store.getState();
163
+ if (!canPlay(status)) {
164
+ this.logger?.warn(`[PlayerEngine] Cannot play from ${status}`);
165
+ return false;
166
+ }
167
+ this.startWatchTime();
168
+ return this.transition("playing" /* PLAYING */);
169
+ }
170
+ pause() {
171
+ const { status } = this.store.getState();
172
+ if (!canPause(status)) return false;
173
+ this.stopWatchTime();
174
+ return this.transition("paused" /* PAUSED */);
175
+ }
176
+ togglePlay() {
177
+ const { status } = this.store.getState();
178
+ if (status === "playing" /* PLAYING */ || status === "buffering" /* BUFFERING */) {
179
+ return this.pause();
180
+ }
181
+ return this.play();
182
+ }
183
+ seek(time) {
184
+ const { status, duration } = this.store.getState();
185
+ if (!canSeek(status)) return false;
186
+ const clamped = Math.max(0, Math.min(time, duration));
187
+ this.store.setState({ currentTime: clamped });
188
+ this.analytics?.trackPlaybackEvent(
189
+ this.store.getState().currentVideoId ?? "",
190
+ "seek",
191
+ clamped
192
+ );
193
+ return true;
194
+ }
195
+ // ═══════════════════════════════════════════
196
+ // PUBLIC API — Volume
197
+ // ═══════════════════════════════════════════
198
+ setMuted(muted) {
199
+ this.store.setState({ muted });
200
+ }
201
+ toggleMute() {
202
+ this.store.setState((s) => ({ muted: !s.muted }));
203
+ }
204
+ setVolume(volume) {
205
+ this.store.setState({ volume: Math.max(0, Math.min(1, volume)) });
206
+ }
207
+ setPlaybackRate(rate) {
208
+ this.store.setState({ playbackRate: rate });
209
+ }
210
+ // ═══════════════════════════════════════════
211
+ // PUBLIC API — Video Element Event Handlers
212
+ // ═══════════════════════════════════════════
213
+ /** Called when <video> fires `canplay` */
214
+ onCanPlay() {
215
+ const { status } = this.store.getState();
216
+ if (status === "loading" /* LOADING */ || status === "buffering" /* BUFFERING */) {
217
+ this.circuit.consecutiveErrors = 0;
218
+ this.play();
219
+ }
220
+ }
221
+ /** Called when <video> fires `waiting` */
222
+ onWaiting() {
223
+ const { status } = this.store.getState();
224
+ if (status !== "playing" /* PLAYING */) return false;
225
+ this.stopWatchTime();
226
+ return this.transition("buffering" /* BUFFERING */);
227
+ }
228
+ /** Called when <video> fires `playing` (after buffering) */
229
+ onPlaying() {
230
+ const { status } = this.store.getState();
231
+ if (status !== "buffering" /* BUFFERING */ && status !== "loading" /* LOADING */) return false;
232
+ this.startWatchTime();
233
+ return this.transition("playing" /* PLAYING */);
234
+ }
235
+ /** Called when <video> fires `timeupdate` */
236
+ onTimeUpdate(currentTime) {
237
+ this.store.setState({ currentTime });
238
+ }
239
+ /** Called when <video> fires `progress` (buffer update) */
240
+ onProgress(buffered) {
241
+ this.store.setState({ buffered });
242
+ }
243
+ /** Called when <video> fires `loadedmetadata` */
244
+ onLoadedMetadata(duration) {
245
+ this.store.setState({ duration });
246
+ }
247
+ /** Called when <video> fires `ended` */
248
+ onEnded() {
249
+ const state = this.store.getState();
250
+ this.stopWatchTime();
251
+ this.store.setState((s) => ({
252
+ ended: true,
253
+ loopCount: s.loopCount + 1
254
+ }));
255
+ this.analytics?.trackView(state.currentVideoId ?? "", state.watchTime);
256
+ this.emit({
257
+ type: "ended",
258
+ videoId: state.currentVideoId ?? "",
259
+ watchTime: state.watchTime,
260
+ loopCount: state.loopCount + 1
261
+ });
262
+ }
263
+ /** Called when <video> fires `error` */
264
+ onError(code, message) {
265
+ const error = {
266
+ code,
267
+ message,
268
+ recoverable: code !== "DECODE_ERROR" && code !== "NOT_SUPPORTED"
269
+ };
270
+ this.stopWatchTime();
271
+ this.recordCircuitError(error);
272
+ this.store.setState({ error });
273
+ this.transition("error" /* ERROR */);
274
+ this.emit({ type: "error", error });
275
+ this.analytics?.trackError(this.store.getState().currentVideoId ?? "", message);
276
+ }
277
+ // ═══════════════════════════════════════════
278
+ // PUBLIC API — Session Restore
279
+ // ═══════════════════════════════════════════
280
+ setPendingRestore(videoId, time) {
281
+ this.store.setState({
282
+ pendingRestoreVideoId: videoId,
283
+ pendingRestoreTime: time
284
+ });
285
+ }
286
+ consumePendingRestore(videoId) {
287
+ const { pendingRestoreVideoId, pendingRestoreTime } = this.store.getState();
288
+ if (pendingRestoreVideoId === videoId && pendingRestoreTime !== null) {
289
+ this.store.setState({ pendingRestoreTime: null, pendingRestoreVideoId: null });
290
+ return pendingRestoreTime;
291
+ }
292
+ return null;
293
+ }
294
+ // ═══════════════════════════════════════════
295
+ // PUBLIC API — Events
296
+ // ═══════════════════════════════════════════
297
+ on(listener) {
298
+ this.listeners.add(listener);
299
+ return () => this.listeners.delete(listener);
300
+ }
301
+ // ═══════════════════════════════════════════
302
+ // PUBLIC API — Lifecycle
303
+ // ═══════════════════════════════════════════
304
+ reset() {
305
+ this.stopWatchTime();
306
+ this.store.setState(
307
+ createInitialState(this.config.defaultMuted, this.config.defaultVolume)
308
+ );
309
+ this.lastStatus = "idle" /* IDLE */;
310
+ }
311
+ destroy() {
312
+ this.stopWatchTime();
313
+ if (this.circuitResetTimer) clearTimeout(this.circuitResetTimer);
314
+ this.listeners.clear();
315
+ }
316
+ // ═══════════════════════════════════════════
317
+ // PRIVATE — State Machine
318
+ // ═══════════════════════════════════════════
319
+ transition(to) {
320
+ const from = this.store.getState().status;
321
+ if (!isValidTransition(from, to)) {
322
+ this.logger?.warn(`[PlayerEngine] Invalid transition: ${from} \u2192 ${to}`);
323
+ return false;
324
+ }
325
+ this.store.setState({ status: to });
326
+ return true;
327
+ }
328
+ // ═══════════════════════════════════════════
329
+ // PRIVATE — Watch Time
330
+ // ═══════════════════════════════════════════
331
+ startWatchTime() {
332
+ if (this.watchTimeInterval) return;
333
+ this.watchTimeInterval = setInterval(() => {
334
+ this.store.setState((s) => ({ watchTime: s.watchTime + 1 }));
335
+ }, 1e3);
336
+ }
337
+ stopWatchTime() {
338
+ if (this.watchTimeInterval) {
339
+ clearInterval(this.watchTimeInterval);
340
+ this.watchTimeInterval = null;
341
+ }
342
+ }
343
+ // ═══════════════════════════════════════════
344
+ // PRIVATE — Circuit Breaker
345
+ // ═══════════════════════════════════════════
346
+ checkCircuit() {
347
+ if (this.circuit.state === "closed" /* CLOSED */) return true;
348
+ if (this.circuit.state === "half_open" /* HALF_OPEN */) return true;
349
+ const elapsed = Date.now() - (this.circuit.openedAt ?? 0);
350
+ if (elapsed >= this.config.circuitBreakerResetMs) {
351
+ this.circuit.state = "half_open" /* HALF_OPEN */;
352
+ this.logger?.info("[PlayerEngine] Circuit HALF_OPEN \u2014 trying recovery");
353
+ return true;
354
+ }
355
+ return false;
356
+ }
357
+ recordCircuitError(error) {
358
+ this.circuit.consecutiveErrors += 1;
359
+ this.circuit.lastError = error;
360
+ if (this.circuit.consecutiveErrors >= this.config.circuitBreakerThreshold) {
361
+ this.circuit.state = "open" /* OPEN */;
362
+ this.circuit.openedAt = Date.now();
363
+ this.logger?.warn(
364
+ `[PlayerEngine] Circuit OPEN after ${this.circuit.consecutiveErrors} errors`
365
+ );
366
+ if (this.circuitResetTimer) clearTimeout(this.circuitResetTimer);
367
+ this.circuitResetTimer = setTimeout(() => {
368
+ this.circuit.state = "half_open" /* HALF_OPEN */;
369
+ this.circuitResetTimer = null;
370
+ }, this.config.circuitBreakerResetMs);
371
+ }
372
+ }
373
+ // ═══════════════════════════════════════════
374
+ // PRIVATE — Analytics
375
+ // ═══════════════════════════════════════════
376
+ trackLeave(state) {
377
+ if (!this.analytics || !state.currentVideoId) return;
378
+ if (state.watchTime > 0) {
379
+ this.analytics.trackView(state.currentVideoId, state.watchTime);
380
+ }
381
+ }
382
+ // ═══════════════════════════════════════════
383
+ // PRIVATE — Events
384
+ // ═══════════════════════════════════════════
385
+ emit(event) {
386
+ for (const listener of this.listeners) {
387
+ try {
388
+ listener(event);
389
+ } catch {
390
+ }
391
+ }
392
+ }
393
+ };
394
+ function createInitialState2() {
395
+ return {
396
+ itemsById: /* @__PURE__ */ new Map(),
397
+ displayOrder: [],
398
+ loading: false,
399
+ loadingMore: false,
400
+ error: null,
401
+ cursor: null,
402
+ hasMore: true,
403
+ isStale: false,
404
+ lastFetchTime: null
405
+ };
406
+ }
407
+ var FeedManager = class {
408
+ constructor(dataSource, config = {}, logger) {
409
+ this.dataSource = dataSource;
410
+ /** Cancel in-flight requests */
411
+ this.abortController = null;
412
+ /** In-flight request deduplication: cursor → Promise */
413
+ this.inFlightRequests = /* @__PURE__ */ new Map();
414
+ /** LRU tracking: itemId → lastAccessTime */
415
+ this.accessOrder = /* @__PURE__ */ new Map();
416
+ /** Prefetch cache — instance-scoped (not static) */
417
+ this.prefetchCache = null;
418
+ this.config = { ...DEFAULT_FEED_CONFIG, ...config };
419
+ this.logger = logger;
420
+ this.store = createStore(createInitialState2);
421
+ }
422
+ // ═══════════════════════════════════════════
423
+ // PUBLIC API — Data Source
424
+ // ═══════════════════════════════════════════
425
+ getDataSource() {
426
+ return this.dataSource;
427
+ }
428
+ setDataSource(dataSource, reset = true) {
429
+ this.dataSource = dataSource;
430
+ this.abortController?.abort();
431
+ this.abortController = null;
432
+ this.inFlightRequests.clear();
433
+ if (reset) {
434
+ this.store.setState(createInitialState2());
435
+ this.accessOrder.clear();
436
+ }
437
+ }
438
+ // ═══════════════════════════════════════════
439
+ // PUBLIC API — Prefetch
440
+ // ═══════════════════════════════════════════
441
+ async prefetch(ttlMs) {
442
+ if (this.prefetchCache) {
443
+ const ttl = ttlMs ?? this.config.staleTTL;
444
+ if (Date.now() - this.prefetchCache.timestamp < ttl) return;
445
+ }
446
+ try {
447
+ const page = await this.dataSource.fetchFeed(null);
448
+ this.prefetchCache = {
449
+ items: page.items,
450
+ nextCursor: page.nextCursor,
451
+ timestamp: Date.now()
452
+ };
453
+ } catch {
454
+ }
455
+ }
456
+ hasPrefetchCache() {
457
+ return this.prefetchCache !== null;
458
+ }
459
+ clearPrefetchCache() {
460
+ this.prefetchCache = null;
461
+ }
462
+ // ═══════════════════════════════════════════
463
+ // PUBLIC API — Loading
464
+ // ═══════════════════════════════════════════
465
+ async loadInitial() {
466
+ const state = this.store.getState();
467
+ if (state.itemsById.size > 0 && state.lastFetchTime) {
468
+ const isStale = Date.now() - state.lastFetchTime > this.config.staleTTL;
469
+ if (!isStale) return;
470
+ this.store.setState({ isStale: true });
471
+ }
472
+ if (this.prefetchCache) {
473
+ const { items, nextCursor } = this.prefetchCache;
474
+ this.prefetchCache = null;
475
+ this.applyItems(items, nextCursor, false);
476
+ this.store.setState({ loading: false, lastFetchTime: Date.now(), isStale: false });
477
+ return;
478
+ }
479
+ await this.fetchPage(null, false);
480
+ }
481
+ async loadMore() {
482
+ const { loadingMore, hasMore, cursor, loading } = this.store.getState();
483
+ if (loadingMore || loading || !hasMore) return;
484
+ await this.fetchPage(cursor, true);
485
+ }
486
+ async refresh() {
487
+ this.abortController?.abort();
488
+ this.abortController = null;
489
+ this.store.setState({
490
+ ...createInitialState2(),
491
+ // Preserve existing items while loading to avoid blank screen
492
+ itemsById: this.store.getState().itemsById,
493
+ displayOrder: this.store.getState().displayOrder,
494
+ isStale: true
495
+ });
496
+ await this.fetchPage(null, false);
497
+ }
498
+ // ═══════════════════════════════════════════
499
+ // PUBLIC API — Items
500
+ // ═══════════════════════════════════════════
501
+ getItems() {
502
+ const { itemsById, displayOrder } = this.store.getState();
503
+ return displayOrder.map((id) => itemsById.get(id)).filter((item) => item !== void 0);
504
+ }
505
+ getItemById(id) {
506
+ this.accessOrder.set(id, Date.now());
507
+ return this.store.getState().itemsById.get(id);
508
+ }
509
+ updateItem(id, patch) {
510
+ const { itemsById } = this.store.getState();
511
+ const item = itemsById.get(id);
512
+ if (!item) return;
513
+ const updated = new Map(itemsById);
514
+ updated.set(id, { ...item, ...patch });
515
+ this.store.setState({ itemsById: updated });
516
+ }
517
+ // ═══════════════════════════════════════════
518
+ // PUBLIC API — Lifecycle
519
+ // ═══════════════════════════════════════════
520
+ destroy() {
521
+ this.abortController?.abort();
522
+ this.inFlightRequests.clear();
523
+ this.accessOrder.clear();
524
+ this.prefetchCache = null;
525
+ }
526
+ // ═══════════════════════════════════════════
527
+ // PRIVATE — Fetch
528
+ // ═══════════════════════════════════════════
529
+ async fetchPage(cursor, isPagination) {
530
+ const cacheKey = cursor ?? "__initial__";
531
+ const inFlight = this.inFlightRequests.get(cacheKey);
532
+ if (inFlight) return inFlight;
533
+ const promise = this.doFetch(cursor, isPagination);
534
+ this.inFlightRequests.set(cacheKey, promise);
535
+ try {
536
+ await promise;
537
+ } finally {
538
+ this.inFlightRequests.delete(cacheKey);
539
+ }
540
+ }
541
+ async doFetch(cursor, isPagination) {
542
+ this.abortController?.abort();
543
+ const controller = new AbortController();
544
+ this.abortController = controller;
545
+ this.store.setState(
546
+ isPagination ? { loadingMore: true, error: null } : { loading: true, error: null }
547
+ );
548
+ let attempt = 0;
549
+ while (attempt <= this.config.maxRetries) {
550
+ if (controller.signal.aborted) return;
551
+ try {
552
+ const page = await this.dataSource.fetchFeed(cursor);
553
+ if (controller.signal.aborted) return;
554
+ this.applyItems(page.items, page.nextCursor, isPagination);
555
+ this.store.setState({
556
+ loading: false,
557
+ loadingMore: false,
558
+ hasMore: page.hasMore,
559
+ cursor: page.nextCursor,
560
+ lastFetchTime: Date.now(),
561
+ isStale: false,
562
+ error: null
563
+ });
564
+ return;
565
+ } catch (err) {
566
+ if (controller.signal.aborted) return;
567
+ attempt += 1;
568
+ if (attempt > this.config.maxRetries) {
569
+ const error = {
570
+ message: err instanceof Error ? err.message : "Unknown error",
571
+ code: "FETCH_FAILED",
572
+ retryable: true
573
+ };
574
+ this.store.setState({ loading: false, loadingMore: false, error });
575
+ this.logger?.error("[FeedManager] Fetch failed after retries", err);
576
+ return;
577
+ }
578
+ const delay = this.config.retryDelay * 2 ** (attempt - 1);
579
+ this.logger?.warn(`[FeedManager] Retrying in ${delay}ms (attempt ${attempt})`);
580
+ await sleep(delay);
581
+ }
582
+ }
583
+ }
584
+ // ═══════════════════════════════════════════
585
+ // PRIVATE — State Mutation
586
+ // ═══════════════════════════════════════════
587
+ applyItems(incoming, _nextCursor, append) {
588
+ const { itemsById, displayOrder } = this.store.getState();
589
+ const nextById = new Map(itemsById);
590
+ const existingIds = new Set(displayOrder);
591
+ const newIds = [];
592
+ for (const item of incoming) {
593
+ if (!existingIds.has(item.id)) {
594
+ newIds.push(item.id);
595
+ }
596
+ nextById.set(item.id, item);
597
+ this.accessOrder.set(item.id, Date.now());
598
+ }
599
+ const nextOrder = append ? [...displayOrder, ...newIds] : newIds;
600
+ if (nextById.size > this.config.maxCacheSize) {
601
+ this.evictLRU(nextById, nextOrder);
602
+ }
603
+ this.store.setState({ itemsById: nextById, displayOrder: nextOrder });
604
+ }
605
+ evictLRU(itemsById, displayOrder) {
606
+ const evictCount = itemsById.size - this.config.maxCacheSize;
607
+ if (evictCount <= 0) return;
608
+ const sorted = [...this.accessOrder.entries()].sort(([, a], [, b]) => a - b);
609
+ let evicted = 0;
610
+ for (const [id] of sorted) {
611
+ if (evicted >= evictCount) break;
612
+ itemsById.delete(id);
613
+ this.accessOrder.delete(id);
614
+ const idx = displayOrder.indexOf(id);
615
+ if (idx !== -1) displayOrder.splice(idx, 1);
616
+ evicted++;
617
+ }
618
+ this.logger?.debug(`[FeedManager] Evicted ${evicted} LRU items`);
619
+ }
620
+ };
621
+ function sleep(ms) {
622
+ return new Promise((resolve) => setTimeout(resolve, ms));
623
+ }
624
+ function createInitialState3() {
625
+ return {
626
+ pendingActions: /* @__PURE__ */ new Map(),
627
+ failedQueue: [],
628
+ hasPending: false,
629
+ isRetrying: false,
630
+ likeDeltas: /* @__PURE__ */ new Map(),
631
+ followState: /* @__PURE__ */ new Map()
632
+ };
633
+ }
634
+ var OptimisticManager = class {
635
+ constructor(interaction, logger) {
636
+ /** Debounce timers: contentId → timer */
637
+ this.likeDebounceTimers = /* @__PURE__ */ new Map();
638
+ /** Pending like direction: contentId → final intended state */
639
+ this.pendingLikeState = /* @__PURE__ */ new Map();
640
+ this.interaction = interaction;
641
+ this.logger = logger;
642
+ this.store = createStore(createInitialState3);
643
+ }
644
+ // ═══════════════════════════════════════════
645
+ // PUBLIC API — Like (debounced toggle)
646
+ // ═══════════════════════════════════════════
647
+ /**
648
+ * Debounced like toggle — prevents rapid API spam on double-tap.
649
+ * UI updates instantly; API call fires after 600ms debounce.
650
+ */
651
+ toggleLike(contentId, currentIsLiked) {
652
+ const pendingState = this.pendingLikeState.get(contentId) ?? currentIsLiked;
653
+ const nextLiked = !pendingState;
654
+ this.pendingLikeState.set(contentId, nextLiked);
655
+ this.store.setState((s) => {
656
+ const nextDeltas = new Map(s.likeDeltas);
657
+ nextDeltas.get(contentId) ?? 0;
658
+ const delta = (nextLiked ? 1 : 0) - (currentIsLiked ? 1 : 0);
659
+ nextDeltas.set(contentId, delta);
660
+ return { likeDeltas: nextDeltas };
661
+ });
662
+ const existing = this.likeDebounceTimers.get(contentId);
663
+ if (existing) clearTimeout(existing);
664
+ const timer = setTimeout(async () => {
665
+ this.likeDebounceTimers.delete(contentId);
666
+ const finalState = this.pendingLikeState.get(contentId) ?? currentIsLiked;
667
+ this.pendingLikeState.delete(contentId);
668
+ try {
669
+ if (finalState) {
670
+ await this.interaction.like?.(contentId);
671
+ } else {
672
+ await this.interaction.unlike?.(contentId);
673
+ }
674
+ this.store.setState((s) => {
675
+ const nextDeltas = new Map(s.likeDeltas);
676
+ nextDeltas.delete(contentId);
677
+ return { likeDeltas: nextDeltas };
678
+ });
679
+ } catch (err) {
680
+ this.store.setState((s) => {
681
+ const nextDeltas = new Map(s.likeDeltas);
682
+ nextDeltas.delete(contentId);
683
+ return { likeDeltas: nextDeltas };
684
+ });
685
+ this.logger?.error("[OptimisticManager] toggleLike failed \u2014 rolled back", err);
686
+ }
687
+ }, 600);
688
+ this.likeDebounceTimers.set(contentId, timer);
689
+ }
690
+ // ═══════════════════════════════════════════
691
+ // PUBLIC API — Follow
692
+ // ═══════════════════════════════════════════
693
+ async toggleFollow(authorId, currentIsFollowing) {
694
+ const nextFollowing = !currentIsFollowing;
695
+ this.store.setState((s) => {
696
+ const next = new Map(s.followState);
697
+ next.set(authorId, nextFollowing);
698
+ return { followState: next };
699
+ });
700
+ try {
701
+ if (nextFollowing) {
702
+ await this.interaction.follow?.(authorId);
703
+ } else {
704
+ await this.interaction.unfollow?.(authorId);
705
+ }
706
+ return true;
707
+ } catch (err) {
708
+ this.store.setState((s) => {
709
+ const next = new Map(s.followState);
710
+ next.delete(authorId);
711
+ return { followState: next };
712
+ });
713
+ this.logger?.error("[OptimisticManager] toggleFollow failed \u2014 rolled back", err);
714
+ return false;
715
+ }
716
+ }
717
+ // ═══════════════════════════════════════════
718
+ // PUBLIC API — Helpers
719
+ // ═══════════════════════════════════════════
720
+ getLikeDelta(contentId) {
721
+ return this.store.getState().likeDeltas.get(contentId) ?? 0;
722
+ }
723
+ getFollowState(authorId) {
724
+ return this.store.getState().followState.get(authorId);
725
+ }
726
+ // ═══════════════════════════════════════════
727
+ // PUBLIC API — Lifecycle
728
+ // ═══════════════════════════════════════════
729
+ destroy() {
730
+ for (const timer of this.likeDebounceTimers.values()) {
731
+ clearTimeout(timer);
732
+ }
733
+ this.likeDebounceTimers.clear();
734
+ this.pendingLikeState.clear();
735
+ }
736
+ };
737
+ var DEFAULT_RESOURCE_CONFIG = {
738
+ maxAllocations: 11,
739
+ bufferWindow: 3,
740
+ warmWindow: 4,
741
+ // 0ms debounce — setFocusedIndexImmediate is already used post-snap.
742
+ focusDebounceMs: 0,
743
+ preloadLookAhead: 3
744
+ };
745
+ var ResourceGovernor = class {
746
+ constructor(config = {}, videoLoader, network, logger) {
747
+ this.focusDebounceTimer = null;
748
+ this.config = { ...DEFAULT_RESOURCE_CONFIG, ...config };
749
+ this.videoLoader = videoLoader;
750
+ this.network = network;
751
+ this.logger = logger;
752
+ this.store = createStore(() => ({
753
+ activeAllocations: /* @__PURE__ */ new Set(),
754
+ warmAllocations: /* @__PURE__ */ new Set(),
755
+ preloadQueue: [],
756
+ focusedIndex: 0,
757
+ totalItems: 0,
758
+ networkType: network?.getNetworkType() ?? "unknown",
759
+ isActive: false,
760
+ prefetchIndex: null
761
+ }));
762
+ }
763
+ // ═══════════════════════════════════════════
764
+ // PUBLIC API — Lifecycle
765
+ // ═══════════════════════════════════════════
766
+ async activate() {
767
+ if (this.store.getState().isActive) return;
768
+ if (this.network) {
769
+ this.networkUnsubscribe = this.network.onNetworkChange((type) => {
770
+ this.store.setState({ networkType: type });
771
+ });
772
+ }
773
+ this.store.setState({ isActive: true });
774
+ this.recalculate();
775
+ this.logger?.debug("[ResourceGovernor] Activated");
776
+ }
777
+ deactivate() {
778
+ this.networkUnsubscribe?.();
779
+ if (this.focusDebounceTimer) clearTimeout(this.focusDebounceTimer);
780
+ this.store.setState({ isActive: false });
781
+ }
782
+ destroy() {
783
+ this.deactivate();
784
+ this.videoLoader?.clearAll();
785
+ }
786
+ // ═══════════════════════════════════════════
787
+ // PUBLIC API — Feed State
788
+ // ═══════════════════════════════════════════
789
+ setTotalItems(count) {
790
+ this.store.setState({ totalItems: count });
791
+ this.recalculate();
792
+ }
793
+ /**
794
+ * Debounced focus update — prevents rapid re-allocations during fast swipe
795
+ */
796
+ setFocusedIndex(index) {
797
+ if (this.focusDebounceTimer) clearTimeout(this.focusDebounceTimer);
798
+ this.focusDebounceTimer = setTimeout(() => {
799
+ this.focusDebounceTimer = null;
800
+ this.setFocusedIndexImmediate(index);
801
+ }, this.config.focusDebounceMs);
802
+ }
803
+ setFocusedIndexImmediate(index) {
804
+ this.store.setState({ focusedIndex: index, prefetchIndex: null });
805
+ this.recalculate();
806
+ }
807
+ /**
808
+ * Signal that the given index should have its video src eagerly loaded.
809
+ * Called by the gesture layer at ~50% drag. Pass null to clear.
810
+ */
811
+ setPrefetchIndex(index) {
812
+ this.store.setState({ prefetchIndex: index });
813
+ }
814
+ // ═══════════════════════════════════════════
815
+ // PUBLIC API — Allocation Queries
816
+ // ═══════════════════════════════════════════
817
+ isAllocated(index) {
818
+ return this.store.getState().activeAllocations.has(index);
819
+ }
820
+ isWarmAllocated(index) {
821
+ return this.store.getState().warmAllocations.has(index);
822
+ }
823
+ shouldRenderVideo(index) {
824
+ const state = this.store.getState();
825
+ return state.activeAllocations.has(index) || state.warmAllocations.has(index);
826
+ }
827
+ isPreloading(index) {
828
+ return this.store.getState().preloadQueue.includes(index);
829
+ }
830
+ getActiveAllocations() {
831
+ return [...this.store.getState().activeAllocations];
832
+ }
833
+ getWarmAllocations() {
834
+ return [...this.store.getState().warmAllocations];
835
+ }
836
+ // ═══════════════════════════════════════════
837
+ // PRIVATE — Allocation Logic (3-Tier)
838
+ // ═══════════════════════════════════════════
839
+ recalculate() {
840
+ const { focusedIndex, totalItems, isActive } = this.store.getState();
841
+ if (!isActive || totalItems === 0) return;
842
+ const { maxAllocations, bufferWindow, warmWindow, preloadLookAhead } = this.config;
843
+ const hotDesired = [];
844
+ hotDesired.push(focusedIndex);
845
+ for (let delta = 1; delta <= bufferWindow; delta++) {
846
+ const ahead = focusedIndex + delta;
847
+ const behind = focusedIndex - delta;
848
+ if (ahead < totalItems) hotDesired.push(ahead);
849
+ if (behind >= 0) hotDesired.push(behind);
850
+ }
851
+ const warmDesired = [];
852
+ const hotEnd = Math.min(totalItems - 1, focusedIndex + bufferWindow);
853
+ const hotStart = Math.max(0, focusedIndex - bufferWindow);
854
+ const forwardWarmCount = Math.max(0, warmWindow - 1);
855
+ for (let i = 1; i <= forwardWarmCount; i++) {
856
+ const idx = hotEnd + i;
857
+ if (idx < totalItems) warmDesired.push(idx);
858
+ }
859
+ const backwardIdx = hotStart - 1;
860
+ if (backwardIdx >= 0) warmDesired.push(backwardIdx);
861
+ const totalDesired = hotDesired.length + warmDesired.length;
862
+ let finalHot = hotDesired;
863
+ let finalWarm = warmDesired;
864
+ if (totalDesired > maxAllocations) {
865
+ const warmBudget = Math.max(0, maxAllocations - hotDesired.length);
866
+ finalWarm = warmDesired.slice(0, warmBudget);
867
+ if (hotDesired.length > maxAllocations) {
868
+ finalHot = hotDesired.slice(0, maxAllocations);
869
+ finalWarm = [];
870
+ }
871
+ }
872
+ const newActiveAllocations = new Set(finalHot);
873
+ const newWarmAllocations = new Set(finalWarm);
874
+ const warmEnd = finalWarm.length > 0 ? Math.max(...finalWarm.filter((i) => i > focusedIndex), hotEnd) : hotEnd;
875
+ const preloadStart = warmEnd + 1;
876
+ const preloadEnd = Math.min(totalItems - 1, preloadStart + preloadLookAhead - 1);
877
+ const preloadQueue = [];
878
+ for (let i = preloadStart; i <= preloadEnd; i++) {
879
+ preloadQueue.push(i);
880
+ }
881
+ this.store.setState({
882
+ activeAllocations: newActiveAllocations,
883
+ warmAllocations: newWarmAllocations,
884
+ preloadQueue
885
+ });
886
+ this.logger?.debug(
887
+ `[ResourceGovernor] Hot: [${finalHot}], Warm: [${finalWarm}], Preload: [${preloadQueue}]`
888
+ );
889
+ }
890
+ };
891
+ function usePointerGesture(config = {}) {
892
+ const {
893
+ axis = "y",
894
+ velocityThreshold = 0.3,
895
+ distanceThreshold = 80,
896
+ disabled = false,
897
+ onDragOffset,
898
+ onDragThreshold,
899
+ containerSize,
900
+ dragThresholdRatio = 0.5,
901
+ onSnap,
902
+ onBounceBack
903
+ } = config;
904
+ const isDraggingRef = useRef(false);
905
+ const dragOffsetRef = useRef(0);
906
+ const startPointRef = useRef(0);
907
+ const startTimeRef = useRef(0);
908
+ const lastPointRef = useRef(0);
909
+ const lastTimeRef = useRef(0);
910
+ const velocityRef = useRef(0);
911
+ const rafIdRef = useRef(null);
912
+ const isLockedRef = useRef(false);
913
+ const startCrossAxisRef = useRef(0);
914
+ const thresholdFiredRef = useRef(false);
915
+ const onDragOffsetRef = useRef(onDragOffset);
916
+ const onDragThresholdRef = useRef(onDragThreshold);
917
+ const onSnapRef = useRef(onSnap);
918
+ const onBounceBackRef = useRef(onBounceBack);
919
+ const disabledRef = useRef(disabled);
920
+ const containerSizeRef = useRef(containerSize);
921
+ const dragThresholdRatioRef = useRef(dragThresholdRatio);
922
+ useEffect(() => {
923
+ onDragOffsetRef.current = onDragOffset;
924
+ onDragThresholdRef.current = onDragThreshold;
925
+ onSnapRef.current = onSnap;
926
+ onBounceBackRef.current = onBounceBack;
927
+ disabledRef.current = disabled;
928
+ containerSizeRef.current = containerSize;
929
+ dragThresholdRatioRef.current = dragThresholdRatio;
930
+ });
931
+ const scheduleFrame = useCallback(
932
+ (offset) => {
933
+ if (rafIdRef.current !== null) {
934
+ cancelAnimationFrame(rafIdRef.current);
935
+ }
936
+ rafIdRef.current = requestAnimationFrame(() => {
937
+ rafIdRef.current = null;
938
+ onDragOffsetRef.current?.(offset);
939
+ });
940
+ },
941
+ []
942
+ );
943
+ const cancelPendingFrame = useCallback(() => {
944
+ if (rafIdRef.current !== null) {
945
+ cancelAnimationFrame(rafIdRef.current);
946
+ rafIdRef.current = null;
947
+ }
948
+ }, []);
949
+ const handlePointerMove = useCallback(
950
+ (e) => {
951
+ if (!isDraggingRef.current || isLockedRef.current) return;
952
+ const mainPos = axis === "y" ? e.clientY : e.clientX;
953
+ const crossPos = axis === "y" ? e.clientX : e.clientY;
954
+ const now = performance.now();
955
+ const mainDelta = mainPos - startPointRef.current;
956
+ const crossDelta = Math.abs(crossPos - startCrossAxisRef.current);
957
+ if (Math.abs(mainDelta) < 20 && crossDelta > Math.abs(mainDelta) * 1.5) {
958
+ isLockedRef.current = true;
959
+ cancelPendingFrame();
960
+ onDragOffsetRef.current?.(0);
961
+ return;
962
+ }
963
+ const dt = now - lastTimeRef.current;
964
+ if (dt > 0) {
965
+ const instantVelocity = (mainPos - lastPointRef.current) / dt;
966
+ velocityRef.current = velocityRef.current * 0.7 + instantVelocity * 0.3;
967
+ }
968
+ lastPointRef.current = mainPos;
969
+ lastTimeRef.current = now;
970
+ const offset = mainPos - startPointRef.current;
971
+ dragOffsetRef.current = offset;
972
+ if (!thresholdFiredRef.current && onDragThresholdRef.current) {
973
+ const size = containerSizeRef.current ?? (axis === "y" ? window.innerHeight : window.innerWidth);
974
+ const threshold = size * dragThresholdRatioRef.current;
975
+ if (Math.abs(offset) >= threshold) {
976
+ thresholdFiredRef.current = true;
977
+ const direction = offset < 0 ? "forward" : "backward";
978
+ onDragThresholdRef.current(direction);
979
+ }
980
+ }
981
+ scheduleFrame(offset);
982
+ },
983
+ [axis, scheduleFrame, cancelPendingFrame]
984
+ );
985
+ const handlePointerUp = useCallback(
986
+ (_e) => {
987
+ if (!isDraggingRef.current) return;
988
+ cancelPendingFrame();
989
+ isDraggingRef.current = false;
990
+ window.removeEventListener("pointermove", handlePointerMove);
991
+ window.removeEventListener("pointerup", handlePointerUp);
992
+ window.removeEventListener("pointercancel", handlePointerUp);
993
+ const offset = dragOffsetRef.current;
994
+ const velocity = velocityRef.current;
995
+ const shouldSnap = Math.abs(velocity) > velocityThreshold || Math.abs(offset) > distanceThreshold;
996
+ if (shouldSnap) {
997
+ const direction = offset < 0 || velocity < 0 ? "forward" : "backward";
998
+ onSnapRef.current?.(direction);
999
+ } else {
1000
+ onBounceBackRef.current?.();
1001
+ }
1002
+ dragOffsetRef.current = 0;
1003
+ velocityRef.current = 0;
1004
+ },
1005
+ [handlePointerMove, velocityThreshold, distanceThreshold, cancelPendingFrame]
1006
+ );
1007
+ const onPointerDown = useCallback(
1008
+ (e) => {
1009
+ if (disabledRef.current) return;
1010
+ if (!e.isPrimary) return;
1011
+ if (e.button !== 0) return;
1012
+ const mainPos = axis === "y" ? e.clientY : e.clientX;
1013
+ const crossPos = axis === "y" ? e.clientX : e.clientY;
1014
+ isDraggingRef.current = true;
1015
+ isLockedRef.current = false;
1016
+ thresholdFiredRef.current = false;
1017
+ startPointRef.current = mainPos;
1018
+ startCrossAxisRef.current = crossPos;
1019
+ startTimeRef.current = performance.now();
1020
+ lastPointRef.current = mainPos;
1021
+ lastTimeRef.current = performance.now();
1022
+ velocityRef.current = 0;
1023
+ dragOffsetRef.current = 0;
1024
+ e.currentTarget.setPointerCapture(e.pointerId);
1025
+ window.addEventListener("pointermove", handlePointerMove, { passive: true });
1026
+ window.addEventListener("pointerup", handlePointerUp);
1027
+ window.addEventListener("pointercancel", handlePointerUp);
1028
+ },
1029
+ [axis, handlePointerMove, handlePointerUp]
1030
+ );
1031
+ useEffect(() => {
1032
+ return () => {
1033
+ cancelPendingFrame();
1034
+ window.removeEventListener("pointermove", handlePointerMove);
1035
+ window.removeEventListener("pointerup", handlePointerUp);
1036
+ window.removeEventListener("pointercancel", handlePointerUp);
1037
+ };
1038
+ }, [cancelPendingFrame, handlePointerMove, handlePointerUp]);
1039
+ return {
1040
+ bind: { onPointerDown },
1041
+ isDraggingRef,
1042
+ dragOffsetRef
1043
+ };
1044
+ }
1045
+ function useSnapAnimation(config = {}) {
1046
+ const { duration = 280, easing = "cubic-bezier(0.25, 0.46, 0.45, 0.94)" } = config;
1047
+ const activeAnimations = useRef([]);
1048
+ const cancelAnimation = useCallback(() => {
1049
+ for (const anim of activeAnimations.current) {
1050
+ anim.cancel();
1051
+ }
1052
+ activeAnimations.current = [];
1053
+ }, []);
1054
+ const runAnimation = useCallback(
1055
+ (targets, animDuration) => {
1056
+ cancelAnimation();
1057
+ const animations = [];
1058
+ for (const { element, fromY, toY } of targets) {
1059
+ element.style.transition = "";
1060
+ const anim = element.animate(
1061
+ [
1062
+ { transform: `translateY(${fromY}px)` },
1063
+ { transform: `translateY(${toY}px)` }
1064
+ ],
1065
+ {
1066
+ duration: animDuration,
1067
+ easing,
1068
+ fill: "forwards"
1069
+ }
1070
+ );
1071
+ anim.addEventListener("finish", () => {
1072
+ element.style.transform = `translateY(${toY}px)`;
1073
+ anim.cancel();
1074
+ });
1075
+ animations.push(anim);
1076
+ }
1077
+ activeAnimations.current = animations;
1078
+ },
1079
+ [duration, easing, cancelAnimation]
1080
+ );
1081
+ const animateSnap = useCallback(
1082
+ (targets) => {
1083
+ runAnimation(targets, duration);
1084
+ },
1085
+ [runAnimation, duration]
1086
+ );
1087
+ const animateBounceBack = useCallback(
1088
+ (targets) => {
1089
+ runAnimation(targets, Math.round(duration * 1.2));
1090
+ },
1091
+ [runAnimation, duration]
1092
+ );
1093
+ useEffect(() => {
1094
+ return () => {
1095
+ cancelAnimation();
1096
+ };
1097
+ }, [cancelAnimation]);
1098
+ return { animateSnap, animateBounceBack, cancelAnimation };
1099
+ }
1100
+ var SDKContext = createContext(null);
1101
+ function ReelsProvider({ children, adapters, debug = false }) {
1102
+ const logger = adapters.logger;
1103
+ const sdkRef = useRef(null);
1104
+ const value = useMemo(() => {
1105
+ if (sdkRef.current) {
1106
+ sdkRef.current.feedManager.destroy();
1107
+ sdkRef.current.playerEngine.destroy();
1108
+ sdkRef.current.resourceGovernor.destroy();
1109
+ sdkRef.current.optimisticManager.destroy();
1110
+ }
1111
+ const feedManager = new FeedManager(adapters.dataSource, {}, logger);
1112
+ const playerEngine = new PlayerEngine(
1113
+ {},
1114
+ adapters.analytics,
1115
+ logger
1116
+ );
1117
+ const resourceGovernor = new ResourceGovernor(
1118
+ {},
1119
+ adapters.videoLoader,
1120
+ adapters.network,
1121
+ logger
1122
+ );
1123
+ const optimisticManager = new OptimisticManager(
1124
+ adapters.interaction ?? {},
1125
+ logger
1126
+ );
1127
+ const instance = {
1128
+ feedManager,
1129
+ playerEngine,
1130
+ resourceGovernor,
1131
+ optimisticManager,
1132
+ adapters
1133
+ };
1134
+ sdkRef.current = instance;
1135
+ return instance;
1136
+ }, [adapters.dataSource]);
1137
+ useEffect(() => {
1138
+ value.resourceGovernor.activate();
1139
+ return () => {
1140
+ value.resourceGovernor.deactivate();
1141
+ };
1142
+ }, [value.resourceGovernor]);
1143
+ useEffect(() => {
1144
+ if (debug) {
1145
+ logger?.debug("[ReelsProvider] Mounted in debug mode");
1146
+ }
1147
+ }, [debug, logger]);
1148
+ useEffect(() => {
1149
+ return () => {
1150
+ sdkRef.current?.feedManager.destroy();
1151
+ sdkRef.current?.playerEngine.destroy();
1152
+ sdkRef.current?.resourceGovernor.destroy();
1153
+ sdkRef.current?.optimisticManager.destroy();
1154
+ };
1155
+ }, []);
1156
+ return /* @__PURE__ */ jsx(SDKContext.Provider, { value, children });
1157
+ }
1158
+ function useSDK() {
1159
+ const ctx = useContext(SDKContext);
1160
+ if (!ctx) {
1161
+ throw new Error("[useSDK] Must be used inside <ReelsProvider>");
1162
+ }
1163
+ return ctx;
1164
+ }
1165
+ function useFeedSelector(selector) {
1166
+ const { feedManager } = useSDK();
1167
+ const selectorRef = useRef(selector);
1168
+ selectorRef.current = selector;
1169
+ const lastSnapshot = useRef(void 0);
1170
+ const lastState = useRef(void 0);
1171
+ const getSnapshot = useCallback(() => {
1172
+ const state = feedManager.store.getState();
1173
+ if (state !== lastState.current) {
1174
+ lastState.current = state;
1175
+ lastSnapshot.current = selectorRef.current(state);
1176
+ }
1177
+ return lastSnapshot.current;
1178
+ }, [feedManager]);
1179
+ return useSyncExternalStore(feedManager.store.subscribe, getSnapshot, getSnapshot);
1180
+ }
1181
+ function useFeed() {
1182
+ const { feedManager } = useSDK();
1183
+ const selectDisplayOrder = useCallback((s) => s.displayOrder, []);
1184
+ const selectLoading = useCallback((s) => s.loading, []);
1185
+ const selectLoadingMore = useCallback((s) => s.loadingMore, []);
1186
+ const selectHasMore = useCallback((s) => s.hasMore, []);
1187
+ const selectError = useCallback((s) => s.error, []);
1188
+ const selectIsStale = useCallback((s) => s.isStale, []);
1189
+ const selectItemsById = useCallback((s) => s.itemsById, []);
1190
+ const displayOrder = useFeedSelector(selectDisplayOrder);
1191
+ const itemsById = useFeedSelector(selectItemsById);
1192
+ const loading = useFeedSelector(selectLoading);
1193
+ const loadingMore = useFeedSelector(selectLoadingMore);
1194
+ const hasMore = useFeedSelector(selectHasMore);
1195
+ const error = useFeedSelector(selectError);
1196
+ const isStale = useFeedSelector(selectIsStale);
1197
+ const items = useMemo(
1198
+ () => displayOrder.map((id) => itemsById.get(id)).filter((item) => item !== void 0),
1199
+ [displayOrder, itemsById]
1200
+ );
1201
+ const loadInitial = useCallback(() => feedManager.loadInitial(), [feedManager]);
1202
+ const loadMore = useCallback(() => feedManager.loadMore(), [feedManager]);
1203
+ const refresh = useCallback(() => feedManager.refresh(), [feedManager]);
1204
+ return {
1205
+ items,
1206
+ loading,
1207
+ loadingMore,
1208
+ hasMore,
1209
+ error,
1210
+ isStale,
1211
+ loadInitial,
1212
+ loadMore,
1213
+ refresh
1214
+ };
1215
+ }
1216
+ function useResourceSelector(selector) {
1217
+ const { resourceGovernor } = useSDK();
1218
+ const selectorRef = useRef(selector);
1219
+ selectorRef.current = selector;
1220
+ const lastSnapshot = useRef(void 0);
1221
+ const lastState = useRef(void 0);
1222
+ const getSnapshot = useCallback(() => {
1223
+ const state = resourceGovernor.store.getState();
1224
+ if (state !== lastState.current) {
1225
+ lastState.current = state;
1226
+ lastSnapshot.current = selectorRef.current(state);
1227
+ }
1228
+ return lastSnapshot.current;
1229
+ }, [resourceGovernor]);
1230
+ return useSyncExternalStore(resourceGovernor.store.subscribe, getSnapshot, getSnapshot);
1231
+ }
1232
+ function useResource() {
1233
+ const { resourceGovernor } = useSDK();
1234
+ const activeAllocations = useResourceSelector((s) => s.activeAllocations);
1235
+ const warmAllocations = useResourceSelector((s) => s.warmAllocations);
1236
+ const focusedIndex = useResourceSelector((s) => s.focusedIndex);
1237
+ const totalItems = useResourceSelector((s) => s.totalItems);
1238
+ const networkType = useResourceSelector((s) => s.networkType);
1239
+ const isActive = useResourceSelector((s) => s.isActive);
1240
+ const prefetchIndex = useResourceSelector((s) => s.prefetchIndex);
1241
+ const activeIndices = useMemo(() => [...activeAllocations], [activeAllocations]);
1242
+ const warmIndices = useMemo(() => [...warmAllocations], [warmAllocations]);
1243
+ const setFocusedIndex = useCallback(
1244
+ (i) => resourceGovernor.setFocusedIndex(i),
1245
+ [resourceGovernor]
1246
+ );
1247
+ const setFocusedIndexImmediate = useCallback(
1248
+ (i) => resourceGovernor.setFocusedIndexImmediate(i),
1249
+ [resourceGovernor]
1250
+ );
1251
+ const setTotalItems = useCallback(
1252
+ (n) => resourceGovernor.setTotalItems(n),
1253
+ [resourceGovernor]
1254
+ );
1255
+ const shouldRenderVideo = useCallback(
1256
+ (i) => resourceGovernor.shouldRenderVideo(i),
1257
+ [resourceGovernor]
1258
+ );
1259
+ const isAllocated = useCallback(
1260
+ (i) => resourceGovernor.isAllocated(i),
1261
+ [resourceGovernor]
1262
+ );
1263
+ const isWarmAllocated = useCallback(
1264
+ (i) => resourceGovernor.isWarmAllocated(i),
1265
+ [resourceGovernor]
1266
+ );
1267
+ const setPrefetchIndex = useCallback(
1268
+ (i) => resourceGovernor.setPrefetchIndex(i),
1269
+ [resourceGovernor]
1270
+ );
1271
+ return {
1272
+ activeIndices,
1273
+ warmIndices,
1274
+ focusedIndex,
1275
+ totalItems,
1276
+ networkType,
1277
+ isActive,
1278
+ prefetchIndex,
1279
+ setFocusedIndex,
1280
+ setFocusedIndexImmediate,
1281
+ setTotalItems,
1282
+ shouldRenderVideo,
1283
+ isAllocated,
1284
+ isWarmAllocated,
1285
+ setPrefetchIndex
1286
+ };
1287
+ }
1288
+ var ACTIVE_HLS_DEFAULTS = {
1289
+ maxBufferLength: 10,
1290
+ maxMaxBufferLength: 15,
1291
+ capLevelToPlayerSize: true,
1292
+ startLevel: 0,
1293
+ abrEwmaDefaultEstimate: 5e5,
1294
+ lowLatencyMode: false,
1295
+ backBufferLength: 5,
1296
+ enableWorker: true
1297
+ };
1298
+ var HOT_HLS_DEFAULTS = {
1299
+ maxBufferLength: 2,
1300
+ maxMaxBufferLength: 3,
1301
+ capLevelToPlayerSize: true,
1302
+ startLevel: 0,
1303
+ abrEwmaDefaultEstimate: 5e5,
1304
+ lowLatencyMode: false,
1305
+ backBufferLength: 0,
1306
+ enableWorker: true
1307
+ };
1308
+ var WARM_HLS_DEFAULTS = {
1309
+ maxBufferLength: 0.5,
1310
+ maxMaxBufferLength: 1,
1311
+ capLevelToPlayerSize: true,
1312
+ startLevel: 0,
1313
+ abrEwmaDefaultEstimate: 5e5,
1314
+ lowLatencyMode: false,
1315
+ backBufferLength: 0,
1316
+ enableWorker: true
1317
+ };
1318
+ function getHlsConfigForTier(tier) {
1319
+ switch (tier) {
1320
+ case "hot":
1321
+ return HOT_HLS_DEFAULTS;
1322
+ case "warm":
1323
+ return WARM_HLS_DEFAULTS;
1324
+ case "active":
1325
+ default:
1326
+ return ACTIVE_HLS_DEFAULTS;
1327
+ }
1328
+ }
1329
+ function supportsNativeHls() {
1330
+ if (typeof document === "undefined") return false;
1331
+ const video = document.createElement("video");
1332
+ return video.canPlayType("application/vnd.apple.mpegurl") !== "";
1333
+ }
1334
+ function mapHlsError(data) {
1335
+ switch (data.type) {
1336
+ case Hls.ErrorTypes.NETWORK_ERROR:
1337
+ return {
1338
+ code: "NETWORK_ERROR",
1339
+ message: `HLS network error: ${data.details} \u2014 ${data.error?.message ?? "unknown"}`
1340
+ };
1341
+ case Hls.ErrorTypes.MEDIA_ERROR:
1342
+ if (data.details === Hls.ErrorDetails.FRAG_PARSING_ERROR || data.details === Hls.ErrorDetails.BUFFER_APPEND_ERROR) {
1343
+ return {
1344
+ code: "DECODE_ERROR",
1345
+ message: `HLS decode error: ${data.details}`
1346
+ };
1347
+ }
1348
+ return {
1349
+ code: "MEDIA_ERROR",
1350
+ message: `HLS media error: ${data.details}`
1351
+ };
1352
+ default:
1353
+ return {
1354
+ code: "UNKNOWN",
1355
+ message: `HLS error: ${data.type} / ${data.details}`
1356
+ };
1357
+ }
1358
+ }
1359
+ function useHls(options) {
1360
+ const { src, videoRef, isActive, isPrefetch, bufferTier = "active", hlsConfig, onError } = options;
1361
+ const isHlsSupported = typeof window !== "undefined" && Hls.isSupported();
1362
+ const isNative = supportsNativeHls();
1363
+ const isHlsJs = isHlsSupported && !isNative;
1364
+ const [isReady, setIsReady] = useState(false);
1365
+ const hlsRef = useRef(null);
1366
+ const onErrorRef = useRef(onError);
1367
+ const mediaRecoveryAttemptedRef = useRef(false);
1368
+ const currentTierRef = useRef(bufferTier);
1369
+ const canPlayFiredRef = useRef(false);
1370
+ onErrorRef.current = onError;
1371
+ const destroy = useCallback(() => {
1372
+ if (hlsRef.current) {
1373
+ hlsRef.current.destroy();
1374
+ hlsRef.current = null;
1375
+ }
1376
+ canPlayFiredRef.current = false;
1377
+ }, []);
1378
+ const currentSrcRef = useRef(void 0);
1379
+ useEffect(() => {
1380
+ const video = videoRef.current;
1381
+ if (!video || !src) {
1382
+ destroy();
1383
+ setIsReady(false);
1384
+ canPlayFiredRef.current = false;
1385
+ currentSrcRef.current = void 0;
1386
+ return;
1387
+ }
1388
+ if (!isActive && !isPrefetch) {
1389
+ destroy();
1390
+ setIsReady(false);
1391
+ canPlayFiredRef.current = false;
1392
+ currentSrcRef.current = void 0;
1393
+ return;
1394
+ }
1395
+ if (isNative) {
1396
+ if (video.src !== src) {
1397
+ video.src = src;
1398
+ }
1399
+ if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
1400
+ setIsReady(true);
1401
+ currentSrcRef.current = src;
1402
+ return;
1403
+ }
1404
+ setIsReady(false);
1405
+ currentSrcRef.current = src;
1406
+ const handleCanPlay2 = () => setIsReady(true);
1407
+ video.addEventListener("canplay", handleCanPlay2, { once: true });
1408
+ return () => {
1409
+ video.removeEventListener("canplay", handleCanPlay2);
1410
+ };
1411
+ }
1412
+ if (!isHlsSupported) {
1413
+ onErrorRef.current?.("UNKNOWN", "HLS playback not supported in this browser");
1414
+ return;
1415
+ }
1416
+ if (hlsRef.current && currentSrcRef.current === src) {
1417
+ if (!canPlayFiredRef.current) {
1418
+ const handleCanPlay2 = () => {
1419
+ canPlayFiredRef.current = true;
1420
+ setIsReady(true);
1421
+ };
1422
+ video.addEventListener("canplay", handleCanPlay2, { once: true });
1423
+ return () => {
1424
+ video.removeEventListener("canplay", handleCanPlay2);
1425
+ };
1426
+ }
1427
+ return void 0;
1428
+ }
1429
+ if (hlsRef.current) {
1430
+ hlsRef.current.destroy();
1431
+ hlsRef.current = null;
1432
+ }
1433
+ setIsReady(false);
1434
+ canPlayFiredRef.current = false;
1435
+ mediaRecoveryAttemptedRef.current = false;
1436
+ currentSrcRef.current = src;
1437
+ const initialTier = bufferTier;
1438
+ currentTierRef.current = initialTier;
1439
+ const tierDefaults = getHlsConfigForTier(initialTier);
1440
+ const config = { ...tierDefaults };
1441
+ if (hlsConfig) {
1442
+ const keys = Object.keys(hlsConfig);
1443
+ for (const key of keys) {
1444
+ config[key] = hlsConfig[key];
1445
+ }
1446
+ }
1447
+ const hls = new Hls(config);
1448
+ hlsRef.current = hls;
1449
+ hls.on(Hls.Events.ERROR, (_event, data) => {
1450
+ if (!data.fatal) {
1451
+ return;
1452
+ }
1453
+ if (data.type === Hls.ErrorTypes.MEDIA_ERROR && !mediaRecoveryAttemptedRef.current) {
1454
+ mediaRecoveryAttemptedRef.current = true;
1455
+ hls.recoverMediaError();
1456
+ return;
1457
+ }
1458
+ const mapped = mapHlsError(data);
1459
+ onErrorRef.current?.(mapped.code, mapped.message);
1460
+ });
1461
+ const handleCanPlay = () => {
1462
+ canPlayFiredRef.current = true;
1463
+ setIsReady(true);
1464
+ };
1465
+ video.addEventListener("canplay", handleCanPlay, { once: true });
1466
+ hls.attachMedia(video);
1467
+ hls.loadSource(src);
1468
+ return () => {
1469
+ video.removeEventListener("canplay", handleCanPlay);
1470
+ if (hlsRef.current === hls) {
1471
+ hls.destroy();
1472
+ hlsRef.current = null;
1473
+ canPlayFiredRef.current = false;
1474
+ currentSrcRef.current = void 0;
1475
+ }
1476
+ };
1477
+ }, [src, isActive, isPrefetch]);
1478
+ useEffect(() => {
1479
+ const hls = hlsRef.current;
1480
+ if (!hls) {
1481
+ currentTierRef.current = bufferTier;
1482
+ return;
1483
+ }
1484
+ const prevTier = currentTierRef.current;
1485
+ if (prevTier === bufferTier) return;
1486
+ currentTierRef.current = bufferTier;
1487
+ const newConfig = getHlsConfigForTier(bufferTier);
1488
+ const hlsAnyConfig = hls.config;
1489
+ const configKeys = Object.keys(newConfig);
1490
+ for (const key of configKeys) {
1491
+ hlsAnyConfig[key] = newConfig[key];
1492
+ }
1493
+ }, [bufferTier]);
1494
+ return {
1495
+ isHlsJs,
1496
+ isReady,
1497
+ destroy
1498
+ };
1499
+ }
1500
+ function DefaultOverlay({ item }) {
1501
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1502
+ /* @__PURE__ */ jsxs("div", { style: { fontWeight: 700, fontSize: 15, marginBottom: 4 }, children: [
1503
+ "@",
1504
+ item.author.name
1505
+ ] }),
1506
+ item.title && /* @__PURE__ */ jsx("div", { style: { fontSize: 13, color: "#ddd", lineHeight: 1.4 }, children: item.title })
1507
+ ] });
1508
+ }
1509
+ function DefaultActions({ item, actions }) {
1510
+ const likes = item.stats.likes + actions.likeDelta;
1511
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1512
+ /* @__PURE__ */ jsx(ActionBtn, { icon: "\u2764\uFE0F", count: formatCount(likes) }),
1513
+ /* @__PURE__ */ jsx(ActionBtn, { icon: "\u{1F4AC}", count: formatCount(item.stats.comments) }),
1514
+ /* @__PURE__ */ jsx(ActionBtn, { icon: "\u2197\uFE0F", count: "Share" })
1515
+ ] });
1516
+ }
1517
+ function ActionBtn({ icon, count }) {
1518
+ return /* @__PURE__ */ jsxs("div", { style: { textAlign: "center", cursor: "pointer" }, children: [
1519
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 28 }, children: icon }),
1520
+ /* @__PURE__ */ jsx("div", { style: { fontSize: 11, color: "#fff", marginTop: 2 }, children: count })
1521
+ ] });
1522
+ }
1523
+ function formatCount(n) {
1524
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
1525
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
1526
+ return String(n);
1527
+ }
1528
+ function DefaultSkeleton() {
1529
+ return /* @__PURE__ */ jsxs(
1530
+ "div",
1531
+ {
1532
+ style: {
1533
+ width: "100%",
1534
+ height: "100dvh",
1535
+ background: "#111",
1536
+ position: "relative",
1537
+ overflow: "hidden"
1538
+ },
1539
+ children: [
1540
+ /* @__PURE__ */ jsx(
1541
+ "div",
1542
+ {
1543
+ style: {
1544
+ position: "absolute",
1545
+ inset: 0,
1546
+ background: "linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.04) 50%, transparent 100%)",
1547
+ animation: "reels-sdk-shimmer 1.5s ease-in-out infinite"
1548
+ }
1549
+ }
1550
+ ),
1551
+ /* @__PURE__ */ jsxs(
1552
+ "div",
1553
+ {
1554
+ style: {
1555
+ position: "absolute",
1556
+ bottom: 100,
1557
+ left: 16,
1558
+ display: "flex",
1559
+ flexDirection: "column",
1560
+ gap: 8
1561
+ },
1562
+ children: [
1563
+ /* @__PURE__ */ jsx("div", { style: skeletonBar(120, 14) }),
1564
+ /* @__PURE__ */ jsx("div", { style: skeletonBar(200, 12) }),
1565
+ /* @__PURE__ */ jsx("div", { style: skeletonBar(160, 12) })
1566
+ ]
1567
+ }
1568
+ ),
1569
+ /* @__PURE__ */ jsxs(
1570
+ "div",
1571
+ {
1572
+ style: {
1573
+ position: "absolute",
1574
+ bottom: 100,
1575
+ right: 16,
1576
+ display: "flex",
1577
+ flexDirection: "column",
1578
+ gap: 20,
1579
+ alignItems: "center"
1580
+ },
1581
+ children: [
1582
+ /* @__PURE__ */ jsx("div", { style: skeletonCircle(40) }),
1583
+ /* @__PURE__ */ jsx("div", { style: skeletonCircle(40) }),
1584
+ /* @__PURE__ */ jsx("div", { style: skeletonCircle(40) })
1585
+ ]
1586
+ }
1587
+ ),
1588
+ /* @__PURE__ */ jsx("style", { children: `
1589
+ @keyframes reels-sdk-shimmer {
1590
+ 0% { transform: translateX(-100%); }
1591
+ 100% { transform: translateX(100%); }
1592
+ }
1593
+ ` })
1594
+ ]
1595
+ }
1596
+ );
1597
+ }
1598
+ function skeletonBar(width, height) {
1599
+ return {
1600
+ width,
1601
+ height,
1602
+ borderRadius: height / 2,
1603
+ background: "rgba(255,255,255,0.1)"
1604
+ };
1605
+ }
1606
+ function skeletonCircle(size) {
1607
+ return {
1608
+ width: size,
1609
+ height: size,
1610
+ borderRadius: "50%",
1611
+ background: "rgba(255,255,255,0.1)"
1612
+ };
1613
+ }
1614
+ function VideoSlot({
1615
+ item,
1616
+ index,
1617
+ isActive,
1618
+ isPrefetch,
1619
+ isPreloaded,
1620
+ bufferTier,
1621
+ isMuted,
1622
+ onToggleMute,
1623
+ showFps = false,
1624
+ renderOverlay,
1625
+ renderActions
1626
+ }) {
1627
+ const { optimisticManager, adapters } = useSDK();
1628
+ if (!isVideoItem(item)) {
1629
+ return /* @__PURE__ */ jsx(
1630
+ "div",
1631
+ {
1632
+ style: {
1633
+ position: "relative",
1634
+ width: "100%",
1635
+ height: "100%",
1636
+ background: "#111",
1637
+ display: "flex",
1638
+ alignItems: "center",
1639
+ justifyContent: "center",
1640
+ color: "#fff",
1641
+ fontSize: 14
1642
+ },
1643
+ children: /* @__PURE__ */ jsx("span", { children: "Non-video content" })
1644
+ }
1645
+ );
1646
+ }
1647
+ return /* @__PURE__ */ jsx(
1648
+ VideoSlotInner,
1649
+ {
1650
+ item,
1651
+ index,
1652
+ isActive,
1653
+ isPrefetch,
1654
+ isPreloaded,
1655
+ bufferTier,
1656
+ isMuted,
1657
+ onToggleMute,
1658
+ showFps,
1659
+ renderOverlay,
1660
+ renderActions,
1661
+ optimisticManager,
1662
+ adapters
1663
+ }
1664
+ );
1665
+ }
1666
+ function VideoSlotInner({
1667
+ item,
1668
+ index,
1669
+ isActive,
1670
+ isPrefetch,
1671
+ isPreloaded,
1672
+ bufferTier,
1673
+ isMuted,
1674
+ onToggleMute,
1675
+ showFps,
1676
+ renderOverlay,
1677
+ renderActions,
1678
+ optimisticManager,
1679
+ adapters
1680
+ }) {
1681
+ const videoRef = useRef(null);
1682
+ const shouldLoadSrc = isActive || isPrefetch || isPreloaded;
1683
+ const src = item.source.url;
1684
+ const sourceType = item.source.type;
1685
+ const isHlsSource = sourceType === "hls";
1686
+ const hlsSrc = isHlsSource && shouldLoadSrc ? src : void 0;
1687
+ const mp4Src = !isHlsSource && shouldLoadSrc ? src : void 0;
1688
+ const { isReady: hlsReady } = useHls({
1689
+ src: hlsSrc,
1690
+ videoRef,
1691
+ isActive,
1692
+ // Pass true for isPrefetch when the slot is preloaded (hot/warm tier)
1693
+ // so useHls creates the HLS instance and starts buffering
1694
+ isPrefetch: isPrefetch || isPreloaded,
1695
+ bufferTier,
1696
+ onError: (code, message) => {
1697
+ console.error(`[VideoSlot] HLS error: ${code} \u2014 ${message}`);
1698
+ }
1699
+ });
1700
+ const [mp4Ready, setMp4Ready] = useState(false);
1701
+ useEffect(() => {
1702
+ if (isHlsSource) return;
1703
+ const video = videoRef.current;
1704
+ if (!video || !mp4Src) {
1705
+ setMp4Ready(false);
1706
+ return;
1707
+ }
1708
+ if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
1709
+ setMp4Ready(true);
1710
+ return;
1711
+ }
1712
+ setMp4Ready(false);
1713
+ const onCanPlay = () => setMp4Ready(true);
1714
+ video.addEventListener("canplay", onCanPlay, { once: true });
1715
+ return () => video.removeEventListener("canplay", onCanPlay);
1716
+ }, [mp4Src, isHlsSource]);
1717
+ useEffect(() => {
1718
+ if (isHlsSource) return;
1719
+ const video = videoRef.current;
1720
+ if (!video || !mp4Src) return;
1721
+ if (!isActive && (isPrefetch || isPreloaded)) {
1722
+ video.load();
1723
+ }
1724
+ }, [mp4Src, isActive, isPrefetch, isPreloaded, isHlsSource]);
1725
+ const isReady = isHlsSource ? hlsReady : mp4Ready;
1726
+ const [hasPlayedAhead, setHasPlayedAhead] = useState(false);
1727
+ useEffect(() => {
1728
+ const video = videoRef.current;
1729
+ if (!video) return;
1730
+ if (isActive || !isReady) return;
1731
+ if (hasPlayedAhead) return;
1732
+ const prevMuted = video.muted;
1733
+ video.muted = true;
1734
+ let cancelled = false;
1735
+ const doPlayAhead = async () => {
1736
+ try {
1737
+ await video.play();
1738
+ if (cancelled) return;
1739
+ const pauseAfterDecode = () => {
1740
+ if (cancelled) return;
1741
+ video.pause();
1742
+ video.currentTime = 0;
1743
+ video.muted = prevMuted;
1744
+ setHasPlayedAhead(true);
1745
+ };
1746
+ setTimeout(pauseAfterDecode, 50);
1747
+ } catch {
1748
+ }
1749
+ };
1750
+ doPlayAhead();
1751
+ return () => {
1752
+ cancelled = true;
1753
+ };
1754
+ }, [isActive, isReady, hasPlayedAhead]);
1755
+ useEffect(() => {
1756
+ setHasPlayedAhead(false);
1757
+ }, [src]);
1758
+ const wasActiveRef = useRef(false);
1759
+ useEffect(() => {
1760
+ const video = videoRef.current;
1761
+ if (!video) return;
1762
+ let onReady = null;
1763
+ if (isActive) {
1764
+ wasActiveRef.current = true;
1765
+ video.muted = isMuted;
1766
+ if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
1767
+ video.play().catch(() => {
1768
+ });
1769
+ } else {
1770
+ onReady = () => {
1771
+ video.play().catch(() => {
1772
+ });
1773
+ };
1774
+ video.addEventListener("canplay", onReady, { once: true });
1775
+ }
1776
+ } else if (wasActiveRef.current) {
1777
+ video.pause();
1778
+ video.currentTime = 0;
1779
+ wasActiveRef.current = false;
1780
+ setHasPlayedAhead(false);
1781
+ } else if (!hasPlayedAhead) {
1782
+ video.pause();
1783
+ }
1784
+ return () => {
1785
+ if (onReady) video.removeEventListener("canplay", onReady);
1786
+ };
1787
+ }, [isActive, isMuted, hasPlayedAhead]);
1788
+ useEffect(() => {
1789
+ const video = videoRef.current;
1790
+ if (!video) return;
1791
+ video.muted = isMuted;
1792
+ }, [isMuted]);
1793
+ const showPosterOverlay = !isReady && !hasPlayedAhead;
1794
+ const [showMuteIndicator, setShowMuteIndicator] = useState(false);
1795
+ const muteIndicatorTimer = useRef(null);
1796
+ const handleTap = useCallback(() => {
1797
+ onToggleMute();
1798
+ setShowMuteIndicator(true);
1799
+ if (muteIndicatorTimer.current) clearTimeout(muteIndicatorTimer.current);
1800
+ muteIndicatorTimer.current = setTimeout(() => setShowMuteIndicator(false), 1200);
1801
+ }, [onToggleMute]);
1802
+ useEffect(() => {
1803
+ return () => {
1804
+ if (muteIndicatorTimer.current) clearTimeout(muteIndicatorTimer.current);
1805
+ };
1806
+ }, []);
1807
+ const likeDelta = useSyncExternalStore(
1808
+ optimisticManager.store.subscribe,
1809
+ () => optimisticManager.getLikeDelta(item.id),
1810
+ () => optimisticManager.getLikeDelta(item.id)
1811
+ );
1812
+ const followState = useSyncExternalStore(
1813
+ optimisticManager.store.subscribe,
1814
+ () => optimisticManager.getFollowState(item.author.id),
1815
+ () => optimisticManager.getFollowState(item.author.id)
1816
+ );
1817
+ const actions = useMemo(() => ({
1818
+ toggleLike: () => optimisticManager.toggleLike(item.id, item.interaction.isLiked),
1819
+ likeDelta,
1820
+ toggleFollow: () => optimisticManager.toggleFollow(item.author.id, item.interaction.isFollowing),
1821
+ followState,
1822
+ share: () => adapters.interaction?.share?.(item.id),
1823
+ isMuted,
1824
+ toggleMute: onToggleMute,
1825
+ isActive,
1826
+ index
1827
+ }), [item, likeDelta, followState, isMuted, isActive, index, optimisticManager, adapters, onToggleMute]);
1828
+ return /* @__PURE__ */ jsxs(
1829
+ "div",
1830
+ {
1831
+ style: {
1832
+ position: "relative",
1833
+ width: "100%",
1834
+ height: "100%",
1835
+ background: "#111",
1836
+ overflow: "hidden"
1837
+ },
1838
+ onClick: handleTap,
1839
+ children: [
1840
+ /* @__PURE__ */ jsx(
1841
+ "video",
1842
+ {
1843
+ ref: videoRef,
1844
+ src: mp4Src,
1845
+ loop: true,
1846
+ muted: isMuted,
1847
+ playsInline: true,
1848
+ preload: shouldLoadSrc ? "auto" : "none",
1849
+ style: {
1850
+ width: "100%",
1851
+ height: "100%",
1852
+ objectFit: "cover",
1853
+ // Hide video until ready to avoid black frame flash
1854
+ opacity: showPosterOverlay ? 0 : 1,
1855
+ transition: "opacity 0.15s ease"
1856
+ }
1857
+ }
1858
+ ),
1859
+ item.poster && /* @__PURE__ */ jsx(
1860
+ "div",
1861
+ {
1862
+ style: {
1863
+ position: "absolute",
1864
+ inset: 0,
1865
+ backgroundImage: `url(${item.poster})`,
1866
+ backgroundSize: "cover",
1867
+ backgroundPosition: "center",
1868
+ opacity: showPosterOverlay ? 1 : 0,
1869
+ transition: "opacity 0.15s ease",
1870
+ pointerEvents: "none"
1871
+ }
1872
+ }
1873
+ ),
1874
+ showMuteIndicator && /* @__PURE__ */ jsx(
1875
+ "div",
1876
+ {
1877
+ style: {
1878
+ position: "absolute",
1879
+ top: "50%",
1880
+ left: "50%",
1881
+ transform: "translate(-50%, -50%)",
1882
+ background: "rgba(0,0,0,0.6)",
1883
+ borderRadius: "50%",
1884
+ width: 64,
1885
+ height: 64,
1886
+ display: "flex",
1887
+ alignItems: "center",
1888
+ justifyContent: "center",
1889
+ fontSize: 28,
1890
+ pointerEvents: "none",
1891
+ animation: "fadeInOut 1.2s ease forwards"
1892
+ },
1893
+ children: isMuted ? "\u{1F507}" : "\u{1F50A}"
1894
+ }
1895
+ ),
1896
+ /* @__PURE__ */ jsx(
1897
+ "div",
1898
+ {
1899
+ style: {
1900
+ position: "absolute",
1901
+ bottom: 80,
1902
+ left: 16,
1903
+ right: 80,
1904
+ pointerEvents: "none",
1905
+ color: "#fff"
1906
+ },
1907
+ children: renderOverlay ? renderOverlay(item, actions) : /* @__PURE__ */ jsx(DefaultOverlay, { item })
1908
+ }
1909
+ ),
1910
+ /* @__PURE__ */ jsx(
1911
+ "div",
1912
+ {
1913
+ style: {
1914
+ position: "absolute",
1915
+ bottom: 80,
1916
+ right: 16,
1917
+ display: "flex",
1918
+ flexDirection: "column",
1919
+ gap: 20,
1920
+ alignItems: "center"
1921
+ },
1922
+ children: renderActions ? renderActions(item, actions) : /* @__PURE__ */ jsx(DefaultActions, { item, actions })
1923
+ }
1924
+ ),
1925
+ showFps && /* @__PURE__ */ jsx(FpsCounter, {})
1926
+ ]
1927
+ }
1928
+ );
1929
+ }
1930
+ function FpsCounter() {
1931
+ const [fps, setFps] = useState(0);
1932
+ const frameCountRef = useRef(0);
1933
+ const lastTimeRef = useRef(performance.now());
1934
+ useEffect(() => {
1935
+ let rafId;
1936
+ const tick = () => {
1937
+ frameCountRef.current++;
1938
+ const now = performance.now();
1939
+ if (now - lastTimeRef.current >= 1e3) {
1940
+ setFps(frameCountRef.current);
1941
+ frameCountRef.current = 0;
1942
+ lastTimeRef.current = now;
1943
+ }
1944
+ rafId = requestAnimationFrame(tick);
1945
+ };
1946
+ rafId = requestAnimationFrame(tick);
1947
+ return () => cancelAnimationFrame(rafId);
1948
+ }, []);
1949
+ return /* @__PURE__ */ jsxs(
1950
+ "div",
1951
+ {
1952
+ style: {
1953
+ position: "absolute",
1954
+ top: 12,
1955
+ right: 12,
1956
+ background: fps >= 55 ? "#00c853" : fps >= 30 ? "#ffd600" : "#d50000",
1957
+ color: "#000",
1958
+ fontSize: 11,
1959
+ fontWeight: 700,
1960
+ padding: "2px 6px",
1961
+ borderRadius: 4,
1962
+ fontFamily: "monospace"
1963
+ },
1964
+ children: [
1965
+ fps,
1966
+ "fps"
1967
+ ]
1968
+ }
1969
+ );
1970
+ }
1971
+ var centerStyle = {
1972
+ height: "100dvh",
1973
+ display: "flex",
1974
+ alignItems: "center",
1975
+ justifyContent: "center",
1976
+ background: "#000",
1977
+ color: "#fff"
1978
+ };
1979
+ function ReelsFeed({
1980
+ renderOverlay,
1981
+ renderActions,
1982
+ renderLoading,
1983
+ renderEmpty,
1984
+ renderError: _renderError,
1985
+ showFps = false,
1986
+ loadMoreThreshold = 5,
1987
+ onSlotChange,
1988
+ gestureConfig,
1989
+ snapConfig
1990
+ }) {
1991
+ const { items, loading, loadInitial, loadMore, hasMore } = useFeed();
1992
+ const {
1993
+ focusedIndex,
1994
+ prefetchIndex,
1995
+ setFocusedIndexImmediate,
1996
+ setTotalItems,
1997
+ shouldRenderVideo,
1998
+ isWarmAllocated,
1999
+ setPrefetchIndex
2000
+ } = useResource();
2001
+ const [isMuted, setIsMuted] = useState(true);
2002
+ const containerRef = useRef(null);
2003
+ const slotCacheRef = useRef(/* @__PURE__ */ new Map());
2004
+ const activeIndexRef = useRef(0);
2005
+ activeIndexRef.current = focusedIndex;
2006
+ const [isSnapping, setIsSnapping] = useState(false);
2007
+ const { animateSnap, animateBounceBack, cancelAnimation } = useSnapAnimation({
2008
+ duration: snapConfig?.duration ?? 260,
2009
+ easing: snapConfig?.easing ?? "cubic-bezier(0.25, 0.46, 0.45, 0.94)"
2010
+ });
2011
+ useEffect(() => {
2012
+ loadInitial();
2013
+ }, [loadInitial]);
2014
+ useEffect(() => {
2015
+ setTotalItems(items.length);
2016
+ }, [items.length, setTotalItems]);
2017
+ useEffect(() => {
2018
+ if (items.length - focusedIndex <= loadMoreThreshold && hasMore && !loading) {
2019
+ loadMore();
2020
+ }
2021
+ }, [focusedIndex, items.length, hasMore, loading, loadMore, loadMoreThreshold]);
2022
+ useEffect(() => {
2023
+ const container = containerRef.current;
2024
+ if (!container) return;
2025
+ const rebuild = () => {
2026
+ slotCacheRef.current.clear();
2027
+ const slots = container.querySelectorAll("[data-slot-index]");
2028
+ for (const slot of slots) {
2029
+ const idx = Number(slot.dataset.slotIndex);
2030
+ slotCacheRef.current.set(idx, slot);
2031
+ }
2032
+ };
2033
+ rebuild();
2034
+ const observer = new MutationObserver(rebuild);
2035
+ observer.observe(container, { childList: true, subtree: true });
2036
+ return () => observer.disconnect();
2037
+ }, [items.length]);
2038
+ const containerHeight = useRef(
2039
+ typeof window !== "undefined" ? window.innerHeight : 800
2040
+ );
2041
+ useEffect(() => {
2042
+ const container = containerRef.current;
2043
+ if (!container) return;
2044
+ containerHeight.current = container.getBoundingClientRect().height || window.innerHeight;
2045
+ const ro = new ResizeObserver(([entry]) => {
2046
+ if (entry) {
2047
+ containerHeight.current = entry.contentRect.height;
2048
+ applyPositions(activeIndexRef.current, 0, false);
2049
+ }
2050
+ });
2051
+ ro.observe(container);
2052
+ return () => ro.disconnect();
2053
+ }, []);
2054
+ const applyPositions = useCallback((activeIdx, dragOffset = 0, _withTransition = false) => {
2055
+ const h = containerHeight.current;
2056
+ for (const [idx, slot] of slotCacheRef.current) {
2057
+ const targetY = (idx - activeIdx) * h + dragOffset;
2058
+ slot.style.transition = "none";
2059
+ slot.style.transform = `translateY(${targetY}px)`;
2060
+ }
2061
+ }, []);
2062
+ useEffect(() => {
2063
+ applyPositions(focusedIndex, 0, false);
2064
+ }, [focusedIndex, applyPositions]);
2065
+ const handleDragThreshold = useCallback(
2066
+ (direction) => {
2067
+ const current = activeIndexRef.current;
2068
+ const nextIdx = direction === "forward" ? Math.min(current + 1, items.length - 1) : Math.max(current - 1, 0);
2069
+ if (nextIdx !== current) {
2070
+ setPrefetchIndex(nextIdx);
2071
+ }
2072
+ },
2073
+ [items.length, setPrefetchIndex]
2074
+ );
2075
+ const handleSnap = useCallback(
2076
+ (direction) => {
2077
+ const current = activeIndexRef.current;
2078
+ const next = direction === "forward" ? Math.min(current + 1, items.length - 1) : Math.max(current - 1, 0);
2079
+ if (next === current) {
2080
+ const targets2 = [];
2081
+ for (const [idx, el] of slotCacheRef.current) {
2082
+ targets2.push({
2083
+ element: el,
2084
+ fromY: parsePxTranslateY(el),
2085
+ toY: (idx - current) * containerHeight.current
2086
+ });
2087
+ }
2088
+ animateBounceBack(targets2);
2089
+ setPrefetchIndex(null);
2090
+ return;
2091
+ }
2092
+ cancelAnimation();
2093
+ setIsSnapping(true);
2094
+ setPrefetchIndex(null);
2095
+ setFocusedIndexImmediate(next);
2096
+ const nextItem = items[next];
2097
+ if (nextItem) {
2098
+ onSlotChange?.(next, nextItem, current);
2099
+ }
2100
+ const h = containerHeight.current;
2101
+ const targets = [];
2102
+ for (const [idx, el] of slotCacheRef.current) {
2103
+ targets.push({
2104
+ element: el,
2105
+ fromY: parsePxTranslateY(el),
2106
+ toY: (idx - next) * h
2107
+ });
2108
+ }
2109
+ animateSnap(targets);
2110
+ setTimeout(() => setIsSnapping(false), 300);
2111
+ },
2112
+ [items, animateSnap, animateBounceBack, cancelAnimation, setFocusedIndexImmediate, setPrefetchIndex, onSlotChange]
2113
+ );
2114
+ const handleBounceBack = useCallback(() => {
2115
+ const current = activeIndexRef.current;
2116
+ const h = containerHeight.current;
2117
+ const targets = [];
2118
+ for (const [idx, el] of slotCacheRef.current) {
2119
+ targets.push({
2120
+ element: el,
2121
+ fromY: parsePxTranslateY(el),
2122
+ toY: (idx - current) * h
2123
+ });
2124
+ }
2125
+ animateBounceBack(targets);
2126
+ setPrefetchIndex(null);
2127
+ }, [animateBounceBack, setPrefetchIndex]);
2128
+ const { bind } = usePointerGesture({
2129
+ axis: "y",
2130
+ velocityThreshold: gestureConfig?.velocityThreshold ?? 0.3,
2131
+ distanceThreshold: gestureConfig?.distanceThreshold ?? 80,
2132
+ disabled: isSnapping,
2133
+ containerSize: containerHeight.current,
2134
+ dragThresholdRatio: gestureConfig?.dragThresholdRatio ?? 0.5,
2135
+ onDragOffset: (offset) => {
2136
+ applyPositions(activeIndexRef.current, offset, false);
2137
+ },
2138
+ onDragThreshold: handleDragThreshold,
2139
+ onSnap: handleSnap,
2140
+ onBounceBack: handleBounceBack
2141
+ });
2142
+ const getInitialTransformPx = useCallback(
2143
+ (index) => {
2144
+ const h = containerHeight.current;
2145
+ return `translateY(${(index - focusedIndex) * h}px)`;
2146
+ },
2147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2148
+ []
2149
+ // Only for initial render; imperative system takes over after mount
2150
+ );
2151
+ const handleToggleMute = useCallback(() => {
2152
+ setIsMuted((prev) => !prev);
2153
+ }, []);
2154
+ if (loading && items.length === 0) {
2155
+ return /* @__PURE__ */ jsx("div", { style: { ...centerStyle, flexDirection: "column", gap: 0 }, children: renderLoading ? renderLoading() : /* @__PURE__ */ jsx(DefaultSkeleton, {}) });
2156
+ }
2157
+ if (!loading && items.length === 0) {
2158
+ return renderEmpty ? renderEmpty() : /* @__PURE__ */ jsx("div", { style: centerStyle, children: "No videos found" });
2159
+ }
2160
+ return /* @__PURE__ */ jsxs(
2161
+ "div",
2162
+ {
2163
+ ref: containerRef,
2164
+ ...bind,
2165
+ style: {
2166
+ position: "relative",
2167
+ width: "100%",
2168
+ height: "100dvh",
2169
+ overflow: "hidden",
2170
+ background: "#000",
2171
+ touchAction: "none",
2172
+ userSelect: "none"
2173
+ },
2174
+ children: [
2175
+ /* @__PURE__ */ jsx("style", { children: `
2176
+ @keyframes reels-sdk-fadeInOut {
2177
+ 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
2178
+ 15% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
2179
+ 70% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
2180
+ 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
2181
+ }
2182
+ @keyframes reels-sdk-spin {
2183
+ to { transform: rotate(360deg); }
2184
+ }
2185
+ ` }),
2186
+ items.map((item, index) => {
2187
+ const isActive = index === focusedIndex;
2188
+ const isPrefetch = index === prefetchIndex;
2189
+ const isWarm = isWarmAllocated(index);
2190
+ const isVisible = shouldRenderVideo(index) || isPrefetch;
2191
+ const bufferTier = isActive ? "active" : isWarm ? "warm" : "hot";
2192
+ return /* @__PURE__ */ jsx(
2193
+ "div",
2194
+ {
2195
+ "data-slot-index": index,
2196
+ style: {
2197
+ position: "absolute",
2198
+ inset: 0,
2199
+ willChange: "transform",
2200
+ transform: getInitialTransformPx(index)
2201
+ },
2202
+ children: /* @__PURE__ */ jsx(
2203
+ VideoSlot,
2204
+ {
2205
+ item,
2206
+ index,
2207
+ isActive,
2208
+ isPrefetch,
2209
+ isPreloaded: !isActive && !isPrefetch && isVisible,
2210
+ bufferTier,
2211
+ isMuted,
2212
+ onToggleMute: handleToggleMute,
2213
+ showFps: showFps && isActive,
2214
+ renderOverlay,
2215
+ renderActions
2216
+ }
2217
+ )
2218
+ },
2219
+ item.id
2220
+ );
2221
+ })
2222
+ ]
2223
+ }
2224
+ );
2225
+ }
2226
+ function parsePxTranslateY(el) {
2227
+ const transform = el.style.transform;
2228
+ if (!transform) return 0;
2229
+ const match = transform.match(/translateY\((-?[\d.]+)/);
2230
+ if (!match || !match[1]) return 0;
2231
+ return Number.parseFloat(match[1]);
2232
+ }
2233
+ function usePlayerSelector(selector) {
2234
+ const { playerEngine } = useSDK();
2235
+ const selectorRef = useRef(selector);
2236
+ selectorRef.current = selector;
2237
+ const lastSnapshot = useRef(void 0);
2238
+ const lastState = useRef(void 0);
2239
+ const getSnapshot = useCallback(() => {
2240
+ const state = playerEngine.store.getState();
2241
+ if (state !== lastState.current) {
2242
+ lastState.current = state;
2243
+ lastSnapshot.current = selectorRef.current(state);
2244
+ }
2245
+ return lastSnapshot.current;
2246
+ }, [playerEngine]);
2247
+ return useSyncExternalStore(playerEngine.store.subscribe, getSnapshot, getSnapshot);
2248
+ }
2249
+ function usePlayer() {
2250
+ const { playerEngine } = useSDK();
2251
+ const status = usePlayerSelector((s) => s.status);
2252
+ const currentVideo = usePlayerSelector((s) => s.currentVideo);
2253
+ const currentTime = usePlayerSelector((s) => s.currentTime);
2254
+ const duration = usePlayerSelector((s) => s.duration);
2255
+ const buffered = usePlayerSelector((s) => s.buffered);
2256
+ const muted = usePlayerSelector((s) => s.muted);
2257
+ const volume = usePlayerSelector((s) => s.volume);
2258
+ const playbackRate = usePlayerSelector((s) => s.playbackRate);
2259
+ const error = usePlayerSelector((s) => s.error);
2260
+ const loopCount = usePlayerSelector((s) => s.loopCount);
2261
+ const watchTime = usePlayerSelector((s) => s.watchTime);
2262
+ const isPlaying = status === "playing" /* PLAYING */;
2263
+ const isPaused = status === "paused" /* PAUSED */;
2264
+ const isBuffering = status === "buffering" /* BUFFERING */;
2265
+ const isLoading = status === "loading" /* LOADING */;
2266
+ const hasError = status === "error" /* ERROR */;
2267
+ const progress = duration > 0 ? currentTime / duration * 100 : 0;
2268
+ const bufferProgress = duration > 0 ? buffered / duration * 100 : 0;
2269
+ const play = useCallback(() => playerEngine.play(), [playerEngine]);
2270
+ const pause = useCallback(() => playerEngine.pause(), [playerEngine]);
2271
+ const togglePlay = useCallback(() => playerEngine.togglePlay(), [playerEngine]);
2272
+ const seek = useCallback((t) => playerEngine.seek(t), [playerEngine]);
2273
+ const setMuted = useCallback((m) => playerEngine.setMuted(m), [playerEngine]);
2274
+ const toggleMute = useCallback(() => playerEngine.toggleMute(), [playerEngine]);
2275
+ const setVolume = useCallback((v) => playerEngine.setVolume(v), [playerEngine]);
2276
+ const setPlaybackRate = useCallback((r) => playerEngine.setPlaybackRate(r), [playerEngine]);
2277
+ const handlers = {
2278
+ onCanPlay: () => playerEngine.onCanPlay(),
2279
+ onWaiting: () => playerEngine.onWaiting(),
2280
+ onPlaying: () => playerEngine.onPlaying(),
2281
+ onEnded: () => playerEngine.onEnded(),
2282
+ onTimeUpdate: (e) => playerEngine.onTimeUpdate(e.currentTarget.currentTime),
2283
+ onProgress: (e) => {
2284
+ const video = e.currentTarget;
2285
+ if (video.buffered.length > 0) {
2286
+ playerEngine.onProgress(video.buffered.end(video.buffered.length - 1));
2287
+ }
2288
+ },
2289
+ onLoadedMetadata: (e) => playerEngine.onLoadedMetadata(e.currentTarget.duration),
2290
+ onError: () => playerEngine.onError("MEDIA_ERROR", "Video playback error")
2291
+ };
2292
+ return {
2293
+ // State
2294
+ status,
2295
+ currentVideo,
2296
+ currentTime,
2297
+ duration,
2298
+ buffered,
2299
+ muted,
2300
+ volume,
2301
+ playbackRate,
2302
+ error,
2303
+ loopCount,
2304
+ watchTime,
2305
+ // Computed
2306
+ isPlaying,
2307
+ isPaused,
2308
+ isBuffering,
2309
+ isLoading,
2310
+ hasError,
2311
+ progress,
2312
+ bufferProgress,
2313
+ // Controls
2314
+ play,
2315
+ pause,
2316
+ togglePlay,
2317
+ seek,
2318
+ setMuted,
2319
+ toggleMute,
2320
+ setVolume,
2321
+ setPlaybackRate,
2322
+ // Element handlers
2323
+ handlers
2324
+ };
2325
+ }
2326
+
2327
+ // src/adapters/mock/index.ts
2328
+ var MockLogger = class {
2329
+ constructor(prefix = "[MockLogger]") {
2330
+ this.prefix = prefix;
2331
+ }
2332
+ debug(msg, ...args) {
2333
+ console.debug(this.prefix, msg, ...args);
2334
+ }
2335
+ info(msg, ...args) {
2336
+ console.info(this.prefix, msg, ...args);
2337
+ }
2338
+ warn(msg, ...args) {
2339
+ console.warn(this.prefix, msg, ...args);
2340
+ }
2341
+ error(msg, ...args) {
2342
+ console.error(this.prefix, msg, ...args);
2343
+ }
2344
+ };
2345
+ var MockAnalytics = class {
2346
+ constructor() {
2347
+ this.events = [];
2348
+ }
2349
+ log(event, data) {
2350
+ this.events.push({ event, data, ts: Date.now() });
2351
+ }
2352
+ trackView(videoId, duration) {
2353
+ this.log("view", { videoId, duration });
2354
+ }
2355
+ trackLike(videoId, isLiked) {
2356
+ this.log("like", { videoId, isLiked });
2357
+ }
2358
+ trackShare(videoId) {
2359
+ this.log("share", { videoId });
2360
+ }
2361
+ trackComment(videoId) {
2362
+ this.log("comment", { videoId });
2363
+ }
2364
+ trackError(videoId, error) {
2365
+ this.log("error", { videoId, error });
2366
+ }
2367
+ trackPlaybackEvent(videoId, event, position) {
2368
+ this.log("playback", { videoId, event, position });
2369
+ }
2370
+ };
2371
+ var MockInteraction = class {
2372
+ constructor(delayMs = 300) {
2373
+ this.calls = [];
2374
+ this.delay = delayMs;
2375
+ }
2376
+ async simulate(action) {
2377
+ await new Promise((r) => setTimeout(r, this.delay));
2378
+ this.calls.push(action);
2379
+ }
2380
+ async like(id) {
2381
+ await this.simulate(`like:${id}`);
2382
+ }
2383
+ async unlike(id) {
2384
+ await this.simulate(`unlike:${id}`);
2385
+ }
2386
+ async follow(id) {
2387
+ await this.simulate(`follow:${id}`);
2388
+ }
2389
+ async unfollow(id) {
2390
+ await this.simulate(`unfollow:${id}`);
2391
+ }
2392
+ async bookmark(id) {
2393
+ await this.simulate(`bookmark:${id}`);
2394
+ }
2395
+ async unbookmark(id) {
2396
+ await this.simulate(`unbookmark:${id}`);
2397
+ }
2398
+ async share(id) {
2399
+ await this.simulate(`share:${id}`);
2400
+ }
2401
+ };
2402
+ var MockSessionStorage = class {
2403
+ constructor() {
2404
+ this.store = /* @__PURE__ */ new Map();
2405
+ }
2406
+ get(key) {
2407
+ return this.store.get(key) ?? null;
2408
+ }
2409
+ set(key, value) {
2410
+ this.store.set(key, value);
2411
+ }
2412
+ remove(key) {
2413
+ this.store.delete(key);
2414
+ }
2415
+ clear() {
2416
+ this.store.clear();
2417
+ }
2418
+ };
2419
+ var MockNetworkAdapter = class {
2420
+ constructor() {
2421
+ this.type = "wifi";
2422
+ this.callbacks = [];
2423
+ }
2424
+ getNetworkType() {
2425
+ return this.type;
2426
+ }
2427
+ isOnline() {
2428
+ return this.type !== "offline";
2429
+ }
2430
+ onNetworkChange(cb) {
2431
+ this.callbacks.push(cb);
2432
+ return () => {
2433
+ this.callbacks = this.callbacks.filter((c) => c !== cb);
2434
+ };
2435
+ }
2436
+ /** Test helper: simulate network change */
2437
+ simulateChange(type) {
2438
+ this.type = type;
2439
+ this.callbacks.forEach((cb) => cb(type));
2440
+ }
2441
+ };
2442
+ var MockVideoLoader = class {
2443
+ constructor(delayMs = 100) {
2444
+ this.preloaded = /* @__PURE__ */ new Set();
2445
+ this.loading = /* @__PURE__ */ new Set();
2446
+ this.delayMs = delayMs;
2447
+ }
2448
+ async preload(videoId, _url, signal) {
2449
+ this.loading.add(videoId);
2450
+ await new Promise((resolve, reject) => {
2451
+ const t = setTimeout(resolve, this.delayMs);
2452
+ signal?.addEventListener("abort", () => {
2453
+ clearTimeout(t);
2454
+ reject(new Error("aborted"));
2455
+ });
2456
+ });
2457
+ this.loading.delete(videoId);
2458
+ this.preloaded.add(videoId);
2459
+ return { videoId, status: "loaded", loadedBytes: 5e5 };
2460
+ }
2461
+ cancel(videoId) {
2462
+ this.loading.delete(videoId);
2463
+ }
2464
+ isPreloaded(videoId) {
2465
+ return this.preloaded.has(videoId);
2466
+ }
2467
+ getPreloadStatus(videoId) {
2468
+ if (this.preloaded.has(videoId)) return "loaded";
2469
+ if (this.loading.has(videoId)) return "loading";
2470
+ return "idle";
2471
+ }
2472
+ clearAll() {
2473
+ this.preloaded.clear();
2474
+ this.loading.clear();
2475
+ }
2476
+ };
2477
+ var MockDataSource = class {
2478
+ constructor(options = {}) {
2479
+ this.totalItems = options.totalItems ?? 20;
2480
+ this.pageSize = options.pageSize ?? 5;
2481
+ this.delayMs = options.delayMs ?? 500;
2482
+ }
2483
+ async fetchFeed(cursor) {
2484
+ await new Promise((r) => setTimeout(r, this.delayMs));
2485
+ const page = cursor ? Number.parseInt(cursor, 10) : 0;
2486
+ const start = page * this.pageSize;
2487
+ const end = Math.min(start + this.pageSize, this.totalItems);
2488
+ const items = Array.from({ length: end - start }, (_, i) => {
2489
+ const idx = start + i;
2490
+ return {
2491
+ id: `video-${idx}`,
2492
+ type: "video",
2493
+ source: {
2494
+ url: `https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4`,
2495
+ type: "mp4"
2496
+ },
2497
+ poster: `https://picsum.photos/seed/${idx}/400/700`,
2498
+ duration: 30 + idx % 60,
2499
+ title: `Video ${idx}`,
2500
+ description: `Description for video ${idx} #shorts #viral`,
2501
+ author: {
2502
+ id: `author-${idx % 5}`,
2503
+ name: `Creator ${idx % 5}`,
2504
+ avatar: `https://picsum.photos/seed/author-${idx % 5}/100/100`,
2505
+ isVerified: idx % 3 === 0
2506
+ },
2507
+ stats: {
2508
+ likes: 1e3 + idx * 137,
2509
+ comments: 50 + idx * 7,
2510
+ shares: 20 + idx * 3,
2511
+ views: 1e4 + idx * 1500
2512
+ },
2513
+ interaction: {
2514
+ isLiked: false,
2515
+ isBookmarked: false,
2516
+ isFollowing: false
2517
+ }
2518
+ };
2519
+ });
2520
+ const hasMore = end < this.totalItems;
2521
+ const nextCursor = hasMore ? String(page + 1) : null;
2522
+ return { items, nextCursor, hasMore };
2523
+ }
2524
+ };
2525
+ var MockCommentAdapter = class {
2526
+ async fetchComments(contentId, cursor) {
2527
+ await new Promise((r) => setTimeout(r, 300));
2528
+ const page = cursor ? Number.parseInt(cursor, 10) : 0;
2529
+ const items = Array.from({ length: 10 }, (_, i) => ({
2530
+ id: `comment-${contentId}-${page * 10 + i}`,
2531
+ contentId,
2532
+ authorId: `user-${i}`,
2533
+ authorName: `User ${i}`,
2534
+ text: `Comment ${page * 10 + i} on ${contentId}`,
2535
+ createdAt: Date.now() - i * 6e4,
2536
+ likeCount: i * 3,
2537
+ isLiked: false
2538
+ }));
2539
+ return { items, nextCursor: page < 2 ? String(page + 1) : null, hasMore: page < 2, total: 30 };
2540
+ }
2541
+ async postComment(contentId, text) {
2542
+ return {
2543
+ id: `comment-new-${Date.now()}`,
2544
+ contentId,
2545
+ authorId: "me",
2546
+ authorName: "Me",
2547
+ text,
2548
+ createdAt: Date.now(),
2549
+ likeCount: 0,
2550
+ isLiked: false
2551
+ };
2552
+ }
2553
+ async deleteComment(_id) {
2554
+ }
2555
+ async likeComment(_id) {
2556
+ }
2557
+ async unlikeComment(_id) {
2558
+ }
2559
+ };
2560
+
2561
+ // src/adapters/browser/HttpDataSource.ts
2562
+ function getNestedValue(obj, path) {
2563
+ if (!obj || typeof obj !== "object") return void 0;
2564
+ const keys = path.split(".");
2565
+ let current = obj;
2566
+ for (const key of keys) {
2567
+ if (current === null || current === void 0) return void 0;
2568
+ if (typeof current !== "object") return void 0;
2569
+ current = current[key];
2570
+ }
2571
+ return current;
2572
+ }
2573
+ function tryFields(obj, ...paths) {
2574
+ for (const path of paths) {
2575
+ const value = getNestedValue(obj, path);
2576
+ if (value !== void 0 && value !== null) return value;
2577
+ }
2578
+ return void 0;
2579
+ }
2580
+ function toStr(value, fallback = "") {
2581
+ if (value === null || value === void 0) return fallback;
2582
+ return String(value);
2583
+ }
2584
+ function toNum(value, fallback = 0) {
2585
+ if (value === null || value === void 0) return fallback;
2586
+ const n = Number(value);
2587
+ return Number.isNaN(n) ? fallback : n;
2588
+ }
2589
+ function toBool(value, fallback = false) {
2590
+ if (value === null || value === void 0) return fallback;
2591
+ if (typeof value === "boolean") return value;
2592
+ if (typeof value === "string") return value === "true" || value === "1";
2593
+ return Boolean(value);
2594
+ }
2595
+ function transformSource(obj) {
2596
+ const mediaArr = obj["media"];
2597
+ if (Array.isArray(mediaArr) && mediaArr.length > 0) {
2598
+ const first = mediaArr[0];
2599
+ const url2 = toStr(first["url"], "");
2600
+ const type2 = url2.includes(".m3u8") ? "hls" : "mp4";
2601
+ return { url: url2, type: type2 };
2602
+ }
2603
+ const url = toStr(
2604
+ tryFields(obj, "video_url", "url", "source_url", "playback_url", "stream_url"),
2605
+ ""
2606
+ );
2607
+ const type = url.includes(".m3u8") ? "hls" : "mp4";
2608
+ return { url, type };
2609
+ }
2610
+ function transformAuthor(obj) {
2611
+ const authorObj = tryFields(obj, "owner", "user", "author", "creator") ?? obj;
2612
+ const firstName = toStr(tryFields(authorObj, "first_name"), "");
2613
+ const lastName = toStr(tryFields(authorObj, "last_name"), "");
2614
+ const fullName = [firstName, lastName].filter(Boolean).join(" ") || toStr(
2615
+ tryFields(authorObj, "display_name", "name", "username", "nickname"),
2616
+ "Unknown"
2617
+ );
2618
+ return {
2619
+ id: toStr(tryFields(authorObj, "id", "user_id", "author_id"), ""),
2620
+ name: fullName || "Unknown",
2621
+ avatar: toStr(
2622
+ tryFields(authorObj, "avatar", "avatar_url", "profile_picture", "photo"),
2623
+ void 0
2624
+ ) || void 0,
2625
+ isVerified: toBool(tryFields(authorObj, "is_verified", "verified"), false)
2626
+ };
2627
+ }
2628
+ function transformStats(obj) {
2629
+ return {
2630
+ views: toNum(tryFields(obj, "views", "view_count", "play_count"), 0),
2631
+ likes: toNum(tryFields(obj, "likes", "like_count", "digg_count"), 0),
2632
+ comments: toNum(tryFields(obj, "total_comments", "comment_count", "comments"), 0),
2633
+ shares: toNum(tryFields(obj, "share_count", "shares"), 0),
2634
+ bookmarks: toNum(tryFields(obj, "bookmark_count", "bookmarks", "saves"), 0)
2635
+ };
2636
+ }
2637
+ function transformInteraction(obj) {
2638
+ return {
2639
+ isLiked: toBool(tryFields(obj, "liked", "is_liked", "user_liked", "has_liked", "isLiked"), false),
2640
+ isBookmarked: toBool(tryFields(obj, "is_bookmarked", "bookmarked", "saved", "isBookmarked"), false),
2641
+ isFollowing: toBool(tryFields(obj, "is_following", "following", "user_following", "isFollowing"), false)
2642
+ };
2643
+ }
2644
+ function transformVideoItem(raw) {
2645
+ const obj = raw;
2646
+ const source = transformSource(obj);
2647
+ const author = transformAuthor(obj);
2648
+ const stats = transformStats(obj);
2649
+ const interaction = transformInteraction(obj);
2650
+ let poster;
2651
+ const thumbnailObj = obj["thumbnail"];
2652
+ if (thumbnailObj && thumbnailObj["url"]) {
2653
+ poster = toStr(thumbnailObj["url"], "") || void 0;
2654
+ }
2655
+ if (!poster) {
2656
+ const mediaArr2 = obj["media"];
2657
+ if (Array.isArray(mediaArr2) && mediaArr2.length > 0) {
2658
+ const first = mediaArr2[0];
2659
+ poster = toStr(first["poster"], "") || void 0;
2660
+ }
2661
+ }
2662
+ if (!poster) {
2663
+ poster = toStr(
2664
+ tryFields(obj, "thumbnail_url", "cover", "cover_url"),
2665
+ void 0
2666
+ ) || void 0;
2667
+ }
2668
+ let duration = 0;
2669
+ const mediaArr = obj["media"];
2670
+ if (Array.isArray(mediaArr) && mediaArr.length > 0) {
2671
+ const first = mediaArr[0];
2672
+ duration = toNum(first["duration"], 0);
2673
+ }
2674
+ if (duration === 0) {
2675
+ duration = toNum(tryFields(obj, "duration", "duration_seconds", "length", "video_duration"), 0);
2676
+ }
2677
+ return {
2678
+ type: "video",
2679
+ id: toStr(tryFields(obj, "id", "video_id", "_id"), ""),
2680
+ source,
2681
+ poster,
2682
+ duration,
2683
+ title: toStr(tryFields(obj, "title", "caption", "text"), void 0) || void 0,
2684
+ description: toStr(tryFields(obj, "description", "caption", "text", "content"), void 0) || void 0,
2685
+ author,
2686
+ stats,
2687
+ interaction
2688
+ };
2689
+ }
2690
+ function transformArticleImage(raw) {
2691
+ if (typeof raw === "string") return { url: raw };
2692
+ const obj = raw;
2693
+ return {
2694
+ url: toStr(tryFields(obj, "url", "image_url", "src"), "")
2695
+ };
2696
+ }
2697
+ function transformArticle(raw) {
2698
+ const obj = raw;
2699
+ const author = transformAuthor(obj);
2700
+ const stats = transformStats(obj);
2701
+ const interaction = transformInteraction(obj);
2702
+ let rawImages = tryFields(obj, "images", "media", "photos", "gallery");
2703
+ if (!Array.isArray(rawImages) || rawImages.length === 0) {
2704
+ const single = toStr(tryFields(obj, "image", "image_url", "url", "thumbnail", "cover"), "");
2705
+ rawImages = single ? [single] : [];
2706
+ }
2707
+ const images = rawImages.map(transformArticleImage);
2708
+ return {
2709
+ type: "article",
2710
+ id: toStr(tryFields(obj, "id", "article_id", "post_id", "_id"), ""),
2711
+ images,
2712
+ title: toStr(tryFields(obj, "title", "subject"), void 0) || void 0,
2713
+ description: toStr(tryFields(obj, "description", "caption", "text", "content"), void 0) || void 0,
2714
+ author,
2715
+ stats,
2716
+ interaction
2717
+ };
2718
+ }
2719
+ function transformContentItem(raw) {
2720
+ const obj = raw;
2721
+ const type = toStr(tryFields(obj, "type", "content_type", "item_type"), "reel");
2722
+ if (type === "article" || type === "image") {
2723
+ return transformArticle(obj);
2724
+ }
2725
+ return transformVideoItem(obj);
2726
+ }
2727
+ function unwrapFeedResponse(response) {
2728
+ if (!response || typeof response !== "object") {
2729
+ return { items: [], nextCursor: null, hasMore: false };
2730
+ }
2731
+ const obj = response;
2732
+ const dataObj = obj["data"];
2733
+ if (dataObj && typeof dataObj === "object" && !Array.isArray(dataObj)) {
2734
+ const data = dataObj;
2735
+ const reels = data["reels"];
2736
+ if (Array.isArray(reels)) {
2737
+ const nextCursor2 = toStr(tryFields(data, "next_cursor", "nextCursor", "cursor"), "") || null;
2738
+ const hasMore2 = toBool(tryFields(data, "has_next", "has_more", "hasMore", "hasNext"), nextCursor2 !== null);
2739
+ return { items: reels, nextCursor: nextCursor2, hasMore: hasMore2 };
2740
+ }
2741
+ const fallbackItems = tryFields(data, "items", "videos", "results", "content", "feeds");
2742
+ if (Array.isArray(fallbackItems)) {
2743
+ const nextCursor2 = toStr(tryFields(data, "next_cursor", "nextCursor", "cursor"), "") || null;
2744
+ const hasMore2 = toBool(tryFields(data, "has_next", "has_more", "hasMore", "hasNext"), nextCursor2 !== null);
2745
+ return { items: fallbackItems, nextCursor: nextCursor2, hasMore: hasMore2 };
2746
+ }
2747
+ }
2748
+ const rawItems = Array.isArray(obj) ? obj : tryFields(obj, "items", "videos", "results", "content", "feeds");
2749
+ const items = Array.isArray(rawItems) ? rawItems : [];
2750
+ const nextCursor = toStr(
2751
+ tryFields(obj, "next_cursor", "nextCursor", "cursor", "next_page_token"),
2752
+ ""
2753
+ ) || null;
2754
+ const hasMore = nextCursor !== null ? true : toBool(tryFields(obj, "has_more", "hasMore", "has_next", "hasNext"), items.length > 0);
2755
+ return { items, nextCursor, hasMore };
2756
+ }
2757
+ var HttpDataSource = class {
2758
+ constructor(config) {
2759
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
2760
+ this.apiKey = config.apiKey;
2761
+ this.getAccessToken = config.getAccessToken ?? (() => null);
2762
+ this.feedPath = config.feedPath ?? "/reels";
2763
+ this.cursorParam = config.cursorParam ?? "cursor";
2764
+ this.limitParam = config.limitParam ?? "limit";
2765
+ this.pageSize = config.pageSize ?? 10;
2766
+ this.extraHeaders = config.headers ?? {};
2767
+ this.timeoutMs = config.timeoutMs ?? 1e4;
2768
+ this.logger = config.logger;
2769
+ }
2770
+ async fetchFeed(cursor) {
2771
+ try {
2772
+ const params = new URLSearchParams({
2773
+ [this.limitParam]: String(this.pageSize)
2774
+ });
2775
+ if (this.apiKey) {
2776
+ params.set("api_key", this.apiKey);
2777
+ }
2778
+ if (cursor) {
2779
+ params.set(this.cursorParam, cursor);
2780
+ }
2781
+ const url = `${this.baseUrl}${this.feedPath}?${params.toString()}`;
2782
+ const response = await this.fetch(url);
2783
+ const { items: rawItems, nextCursor, hasMore } = unwrapFeedResponse(response);
2784
+ const items = rawItems.map((raw) => {
2785
+ try {
2786
+ return transformContentItem(raw);
2787
+ } catch (err) {
2788
+ this.logger?.warn("[HttpDataSource] Failed to transform item, skipping", String(err));
2789
+ return transformVideoItem(raw);
2790
+ }
2791
+ });
2792
+ this.logger?.debug(`[HttpDataSource] fetchFeed \u2014 ${items.length} items, hasMore=${hasMore}`);
2793
+ return { items, nextCursor, hasMore };
2794
+ } catch (err) {
2795
+ this.logger?.error("[HttpDataSource] fetchFeed failed", String(err));
2796
+ throw err;
2797
+ }
2798
+ }
2799
+ // ─── Private helpers ───
2800
+ async fetch(url) {
2801
+ const controller = new AbortController();
2802
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
2803
+ try {
2804
+ const headers = await this.buildHeaders();
2805
+ const response = await fetch(url, {
2806
+ method: "GET",
2807
+ headers,
2808
+ signal: controller.signal
2809
+ });
2810
+ clearTimeout(timer);
2811
+ if (!response.ok) {
2812
+ const body = await response.text().catch(() => "");
2813
+ throw new HttpError(response.status, `HTTP ${response.status}: ${response.statusText}`, body);
2814
+ }
2815
+ return response.json();
2816
+ } catch (err) {
2817
+ clearTimeout(timer);
2818
+ throw err;
2819
+ }
2820
+ }
2821
+ async buildHeaders() {
2822
+ const headers = {
2823
+ "Content-Type": "application/json",
2824
+ Accept: "application/json",
2825
+ ...this.extraHeaders
2826
+ };
2827
+ if (!this.apiKey) {
2828
+ const token = await this.getAccessToken();
2829
+ if (token) {
2830
+ headers["Authorization"] = `Bearer ${token}`;
2831
+ }
2832
+ }
2833
+ return headers;
2834
+ }
2835
+ };
2836
+ var HttpError = class extends Error {
2837
+ constructor(status, message, body) {
2838
+ super(message);
2839
+ this.status = status;
2840
+ this.body = body;
2841
+ this.name = "HttpError";
2842
+ }
2843
+ };
2844
+
2845
+ export { DEFAULT_FEED_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_RESOURCE_CONFIG, DefaultActions, DefaultOverlay, DefaultSkeleton, FeedManager, HttpDataSource, HttpError, MockAnalytics, MockCommentAdapter, MockDataSource, MockInteraction, MockLogger, MockNetworkAdapter, MockSessionStorage, MockVideoLoader, OptimisticManager, PlayerEngine, PlayerStatus, ReelsFeed, ReelsProvider, ResourceGovernor, VALID_TRANSITIONS, VideoSlot, canPause, canPlay, canSeek, isArticle, isValidTransition, isVideoItem, useFeed, useFeedSelector, useHls, usePlayer, usePlayerSelector, usePointerGesture, useResource, useResourceSelector, useSDK, useSnapAnimation };