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