@xhub-short/core 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1466 -0
- package/dist/index.js +2495 -0
- package/package.json +41 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2495 @@
|
|
|
1
|
+
import { createStore } from 'zustand/vanilla';
|
|
2
|
+
|
|
3
|
+
// src/feed/FeedManager.ts
|
|
4
|
+
|
|
5
|
+
// src/feed/types.ts
|
|
6
|
+
var DEFAULT_FEED_CONFIG = {
|
|
7
|
+
pageSize: 10,
|
|
8
|
+
maxRetries: 3,
|
|
9
|
+
retryBaseDelay: 1e3,
|
|
10
|
+
staleTime: 5 * 60 * 1e3,
|
|
11
|
+
// 5 minutes
|
|
12
|
+
enableSWR: true,
|
|
13
|
+
maxCacheSize: 100,
|
|
14
|
+
enableGC: true
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/feed/FeedManager.ts
|
|
18
|
+
var createInitialState = () => ({
|
|
19
|
+
itemsById: /* @__PURE__ */ new Map(),
|
|
20
|
+
displayOrder: [],
|
|
21
|
+
loading: false,
|
|
22
|
+
loadingMore: false,
|
|
23
|
+
error: null,
|
|
24
|
+
cursor: null,
|
|
25
|
+
hasMore: true,
|
|
26
|
+
isStale: false,
|
|
27
|
+
lastFetchTime: null
|
|
28
|
+
});
|
|
29
|
+
var FeedManager = class {
|
|
30
|
+
constructor(dataSource, config = {}) {
|
|
31
|
+
this.dataSource = dataSource;
|
|
32
|
+
/** Abort controller for cancelling in-flight requests */
|
|
33
|
+
this.abortController = null;
|
|
34
|
+
/**
|
|
35
|
+
* Request deduplication: Map of cursor → in-flight Promise
|
|
36
|
+
* Prevents duplicate API calls for the same cursor
|
|
37
|
+
*/
|
|
38
|
+
this.inFlightRequests = /* @__PURE__ */ new Map();
|
|
39
|
+
/**
|
|
40
|
+
* LRU tracking: Map of videoId → lastAccessTime
|
|
41
|
+
* Used for garbage collection
|
|
42
|
+
*/
|
|
43
|
+
this.accessOrder = /* @__PURE__ */ new Map();
|
|
44
|
+
this.config = { ...DEFAULT_FEED_CONFIG, ...config };
|
|
45
|
+
this.store = createStore(createInitialState);
|
|
46
|
+
}
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════
|
|
48
|
+
// PUBLIC API
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════
|
|
50
|
+
/**
|
|
51
|
+
* Load initial feed data
|
|
52
|
+
*
|
|
53
|
+
* Implements SWR pattern:
|
|
54
|
+
* 1. If cached data exists, show it immediately
|
|
55
|
+
* 2. If data is stale (>staleTime), revalidate in background
|
|
56
|
+
* 3. If no cached data, fetch from network
|
|
57
|
+
*
|
|
58
|
+
* Request Deduplication:
|
|
59
|
+
* - If a request for the same cursor is already in-flight, returns the existing Promise
|
|
60
|
+
* - Prevents duplicate API calls from rapid UI interactions
|
|
61
|
+
*/
|
|
62
|
+
async loadInitial() {
|
|
63
|
+
const dedupeKey = "__initial__";
|
|
64
|
+
const existingRequest = this.inFlightRequests.get(dedupeKey);
|
|
65
|
+
if (existingRequest) {
|
|
66
|
+
return existingRequest;
|
|
67
|
+
}
|
|
68
|
+
const state = this.store.getState();
|
|
69
|
+
if (state.loading) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.store.setState({ loading: true, error: null });
|
|
73
|
+
const request = this.executeLoadInitial();
|
|
74
|
+
this.inFlightRequests.set(dedupeKey, request);
|
|
75
|
+
try {
|
|
76
|
+
await request;
|
|
77
|
+
} finally {
|
|
78
|
+
this.inFlightRequests.delete(dedupeKey);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Internal: Execute load initial logic
|
|
83
|
+
*/
|
|
84
|
+
async executeLoadInitial() {
|
|
85
|
+
try {
|
|
86
|
+
await this.fetchWithRetry();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.handleError(error, "loadInitial");
|
|
89
|
+
} finally {
|
|
90
|
+
this.store.setState({ loading: false });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Load more videos (pagination)
|
|
95
|
+
*
|
|
96
|
+
* Features:
|
|
97
|
+
* - Race condition protection (prevents concurrent calls)
|
|
98
|
+
* - Request deduplication (prevents duplicate API calls for same cursor)
|
|
99
|
+
* - Exponential backoff retry on failure
|
|
100
|
+
*/
|
|
101
|
+
async loadMore() {
|
|
102
|
+
const state = this.store.getState();
|
|
103
|
+
if (state.loading || state.loadingMore || !state.hasMore) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const cursor = state.cursor ?? "__null__";
|
|
107
|
+
const dedupeKey = `loadMore:${cursor}`;
|
|
108
|
+
const existingRequest = this.inFlightRequests.get(dedupeKey);
|
|
109
|
+
if (existingRequest) {
|
|
110
|
+
return existingRequest;
|
|
111
|
+
}
|
|
112
|
+
this.store.setState({ loadingMore: true, error: null });
|
|
113
|
+
const request = this.executeLoadMore(state.cursor ?? void 0);
|
|
114
|
+
this.inFlightRequests.set(dedupeKey, request);
|
|
115
|
+
try {
|
|
116
|
+
await request;
|
|
117
|
+
} finally {
|
|
118
|
+
this.inFlightRequests.delete(dedupeKey);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Internal: Execute load more logic
|
|
123
|
+
*/
|
|
124
|
+
async executeLoadMore(cursor) {
|
|
125
|
+
try {
|
|
126
|
+
await this.fetchWithRetry(cursor);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.handleError(error, "loadMore");
|
|
129
|
+
} finally {
|
|
130
|
+
this.store.setState({ loadingMore: false });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Revalidate feed data in background (SWR pattern)
|
|
135
|
+
*
|
|
136
|
+
* Used when cached data is stale but still shown to user
|
|
137
|
+
*/
|
|
138
|
+
async revalidate() {
|
|
139
|
+
const state = this.store.getState();
|
|
140
|
+
if (state.loading || state.loadingMore) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const response = await this.dataSource.fetchFeed();
|
|
145
|
+
this.mergeVideos(response.items, true);
|
|
146
|
+
this.store.setState({
|
|
147
|
+
cursor: response.nextCursor,
|
|
148
|
+
hasMore: response.hasMore,
|
|
149
|
+
isStale: false,
|
|
150
|
+
lastFetchTime: Date.now()
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get a video by ID
|
|
157
|
+
* Also updates LRU access time for garbage collection
|
|
158
|
+
*/
|
|
159
|
+
getVideo(id) {
|
|
160
|
+
const video = this.store.getState().itemsById.get(id);
|
|
161
|
+
if (video) {
|
|
162
|
+
this.accessOrder.set(id, Date.now());
|
|
163
|
+
}
|
|
164
|
+
return video;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Get ordered list of videos
|
|
168
|
+
*/
|
|
169
|
+
getVideos() {
|
|
170
|
+
const state = this.store.getState();
|
|
171
|
+
return state.displayOrder.map((id) => state.itemsById.get(id)).filter((v) => v !== void 0);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Update a video in the feed (for optimistic updates)
|
|
175
|
+
*/
|
|
176
|
+
updateVideo(id, updates) {
|
|
177
|
+
const state = this.store.getState();
|
|
178
|
+
const existing = state.itemsById.get(id);
|
|
179
|
+
if (existing) {
|
|
180
|
+
const newItemsById = new Map(state.itemsById);
|
|
181
|
+
newItemsById.set(id, { ...existing, ...updates });
|
|
182
|
+
this.store.setState({ itemsById: newItemsById });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Check if data is stale and needs revalidation
|
|
187
|
+
*/
|
|
188
|
+
isStale() {
|
|
189
|
+
const state = this.store.getState();
|
|
190
|
+
if (!state.lastFetchTime) return true;
|
|
191
|
+
return Date.now() - state.lastFetchTime > this.config.staleTime;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Reset feed state
|
|
195
|
+
*/
|
|
196
|
+
reset() {
|
|
197
|
+
this.cancelPendingRequests();
|
|
198
|
+
this.inFlightRequests.clear();
|
|
199
|
+
this.accessOrder.clear();
|
|
200
|
+
this.store.setState(createInitialState());
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Cancel any pending requests
|
|
204
|
+
*/
|
|
205
|
+
cancelPendingRequests() {
|
|
206
|
+
if (this.abortController) {
|
|
207
|
+
this.abortController.abort();
|
|
208
|
+
this.abortController = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Destroy the manager and cleanup
|
|
213
|
+
*/
|
|
214
|
+
destroy() {
|
|
215
|
+
this.cancelPendingRequests();
|
|
216
|
+
this.inFlightRequests.clear();
|
|
217
|
+
this.accessOrder.clear();
|
|
218
|
+
this.store.setState(createInitialState());
|
|
219
|
+
}
|
|
220
|
+
// ═══════════════════════════════════════════════════════════════
|
|
221
|
+
// PRIVATE METHODS
|
|
222
|
+
// ═══════════════════════════════════════════════════════════════
|
|
223
|
+
/**
|
|
224
|
+
* Fetch with exponential backoff retry
|
|
225
|
+
*/
|
|
226
|
+
async fetchWithRetry(cursor) {
|
|
227
|
+
let lastError = null;
|
|
228
|
+
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
229
|
+
try {
|
|
230
|
+
this.abortController = new AbortController();
|
|
231
|
+
const response = await this.dataSource.fetchFeed(cursor);
|
|
232
|
+
this.addVideos(response.items);
|
|
233
|
+
this.store.setState({
|
|
234
|
+
cursor: response.nextCursor,
|
|
235
|
+
hasMore: response.hasMore,
|
|
236
|
+
isStale: false,
|
|
237
|
+
lastFetchTime: Date.now(),
|
|
238
|
+
error: null
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
lastError = error instanceof Error ? error : new Error("Unknown error");
|
|
243
|
+
if (lastError.name === "AbortError") {
|
|
244
|
+
throw lastError;
|
|
245
|
+
}
|
|
246
|
+
if (attempt < this.config.maxRetries) {
|
|
247
|
+
const delay = this.config.retryBaseDelay * 2 ** attempt;
|
|
248
|
+
await this.sleep(delay);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
throw lastError;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Add videos with deduplication
|
|
256
|
+
* Triggers garbage collection if cache exceeds maxCacheSize
|
|
257
|
+
*/
|
|
258
|
+
addVideos(videos) {
|
|
259
|
+
const state = this.store.getState();
|
|
260
|
+
const newItemsById = new Map(state.itemsById);
|
|
261
|
+
const newDisplayOrder = [...state.displayOrder];
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
for (const video of videos) {
|
|
264
|
+
if (!newItemsById.has(video.id)) {
|
|
265
|
+
newItemsById.set(video.id, video);
|
|
266
|
+
newDisplayOrder.push(video.id);
|
|
267
|
+
this.accessOrder.set(video.id, now);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
this.store.setState({
|
|
271
|
+
itemsById: newItemsById,
|
|
272
|
+
displayOrder: newDisplayOrder
|
|
273
|
+
});
|
|
274
|
+
if (this.config.enableGC) {
|
|
275
|
+
this.runGarbageCollection();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Merge videos (for SWR revalidation)
|
|
280
|
+
* Updates existing videos, adds new ones at the beginning
|
|
281
|
+
*/
|
|
282
|
+
mergeVideos(videos, prepend) {
|
|
283
|
+
const state = this.store.getState();
|
|
284
|
+
const newItemsById = new Map(state.itemsById);
|
|
285
|
+
const newIds = [];
|
|
286
|
+
for (const video of videos) {
|
|
287
|
+
if (newItemsById.has(video.id)) {
|
|
288
|
+
newItemsById.set(video.id, video);
|
|
289
|
+
} else {
|
|
290
|
+
newItemsById.set(video.id, video);
|
|
291
|
+
newIds.push(video.id);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const newDisplayOrder = prepend ? [...newIds, ...state.displayOrder] : [...state.displayOrder, ...newIds];
|
|
295
|
+
this.store.setState({
|
|
296
|
+
itemsById: newItemsById,
|
|
297
|
+
displayOrder: newDisplayOrder
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Handle and categorize errors
|
|
302
|
+
*/
|
|
303
|
+
handleError(error, _context) {
|
|
304
|
+
const feedError = this.categorizeError(error);
|
|
305
|
+
this.store.setState({ error: feedError });
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Categorize error for better error handling
|
|
309
|
+
*/
|
|
310
|
+
categorizeError(error) {
|
|
311
|
+
if (error instanceof Error) {
|
|
312
|
+
const lowerMessage = error.message.toLowerCase();
|
|
313
|
+
if (error.name === "TypeError" || lowerMessage.includes("network")) {
|
|
314
|
+
return {
|
|
315
|
+
message: "Network error. Please check your connection.",
|
|
316
|
+
code: "NETWORK_ERROR",
|
|
317
|
+
retryCount: this.config.maxRetries,
|
|
318
|
+
recoverable: true
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (error.name === "AbortError" || lowerMessage.includes("timeout")) {
|
|
322
|
+
return {
|
|
323
|
+
message: "Request timed out. Please try again.",
|
|
324
|
+
code: "TIMEOUT",
|
|
325
|
+
retryCount: this.config.maxRetries,
|
|
326
|
+
recoverable: true
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (lowerMessage.includes("500") || lowerMessage.includes("server")) {
|
|
330
|
+
return {
|
|
331
|
+
message: "Server error. Please try again later.",
|
|
332
|
+
code: "SERVER_ERROR",
|
|
333
|
+
retryCount: this.config.maxRetries,
|
|
334
|
+
recoverable: true
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
message: error.message,
|
|
339
|
+
code: "UNKNOWN",
|
|
340
|
+
retryCount: this.config.maxRetries,
|
|
341
|
+
recoverable: false
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
message: "An unexpected error occurred.",
|
|
346
|
+
code: "UNKNOWN",
|
|
347
|
+
retryCount: this.config.maxRetries,
|
|
348
|
+
recoverable: false
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Sleep utility for retry delays
|
|
353
|
+
*/
|
|
354
|
+
sleep(ms) {
|
|
355
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
356
|
+
}
|
|
357
|
+
// ═══════════════════════════════════════════════════════════════
|
|
358
|
+
// GARBAGE COLLECTION
|
|
359
|
+
// ═══════════════════════════════════════════════════════════════
|
|
360
|
+
/**
|
|
361
|
+
* Run garbage collection using LRU (Least Recently Used) policy
|
|
362
|
+
*
|
|
363
|
+
* When cache size exceeds maxCacheSize:
|
|
364
|
+
* 1. Sort videos by last access time (oldest first)
|
|
365
|
+
* 2. Evict oldest videos until cache is within limit
|
|
366
|
+
* 3. Keep videos that are currently in viewport (most recent in displayOrder)
|
|
367
|
+
*
|
|
368
|
+
* @returns Number of evicted items
|
|
369
|
+
*/
|
|
370
|
+
runGarbageCollection() {
|
|
371
|
+
const state = this.store.getState();
|
|
372
|
+
const currentSize = state.itemsById.size;
|
|
373
|
+
if (currentSize <= this.config.maxCacheSize) {
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
const itemsToEvict = currentSize - this.config.maxCacheSize;
|
|
377
|
+
const sortedByAccess = Array.from(this.accessOrder.entries()).sort((a, b) => a[1] - b[1]).map(([id]) => id);
|
|
378
|
+
const minProtected = 3;
|
|
379
|
+
const protectedCount = Math.max(
|
|
380
|
+
minProtected,
|
|
381
|
+
Math.min(this.config.maxCacheSize, state.displayOrder.length)
|
|
382
|
+
);
|
|
383
|
+
const protectedIds = new Set(state.displayOrder.slice(-protectedCount));
|
|
384
|
+
const evictionCandidates = sortedByAccess.filter((id) => !protectedIds.has(id));
|
|
385
|
+
const actualEvictions = Math.min(itemsToEvict, evictionCandidates.length);
|
|
386
|
+
const idsToEvict = evictionCandidates.slice(0, actualEvictions);
|
|
387
|
+
if (idsToEvict.length === 0) {
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
const newItemsById = new Map(state.itemsById);
|
|
391
|
+
const evictedSet = new Set(idsToEvict);
|
|
392
|
+
for (const id of idsToEvict) {
|
|
393
|
+
newItemsById.delete(id);
|
|
394
|
+
this.accessOrder.delete(id);
|
|
395
|
+
}
|
|
396
|
+
const newDisplayOrder = state.displayOrder.filter((id) => !evictedSet.has(id));
|
|
397
|
+
this.store.setState({
|
|
398
|
+
itemsById: newItemsById,
|
|
399
|
+
displayOrder: newDisplayOrder
|
|
400
|
+
});
|
|
401
|
+
return idsToEvict.length;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Manually trigger garbage collection
|
|
405
|
+
* Useful for debugging or when memory pressure is detected
|
|
406
|
+
*
|
|
407
|
+
* @returns Number of evicted items
|
|
408
|
+
*/
|
|
409
|
+
forceGarbageCollection() {
|
|
410
|
+
return this.runGarbageCollection();
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Get current cache statistics
|
|
414
|
+
* Useful for debugging and monitoring
|
|
415
|
+
*/
|
|
416
|
+
getCacheStats() {
|
|
417
|
+
const state = this.store.getState();
|
|
418
|
+
const accessTimes = Array.from(this.accessOrder.values());
|
|
419
|
+
return {
|
|
420
|
+
size: state.itemsById.size,
|
|
421
|
+
maxSize: this.config.maxCacheSize,
|
|
422
|
+
utilizationPercent: Math.round(state.itemsById.size / this.config.maxCacheSize * 100),
|
|
423
|
+
oldestAccessTime: accessTimes.length > 0 ? Math.min(...accessTimes) : null,
|
|
424
|
+
newestAccessTime: accessTimes.length > 0 ? Math.max(...accessTimes) : null
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// src/player/types.ts
|
|
430
|
+
var PlayerStatus = /* @__PURE__ */ ((PlayerStatus2) => {
|
|
431
|
+
PlayerStatus2["IDLE"] = "idle";
|
|
432
|
+
PlayerStatus2["LOADING"] = "loading";
|
|
433
|
+
PlayerStatus2["PLAYING"] = "playing";
|
|
434
|
+
PlayerStatus2["PAUSED"] = "paused";
|
|
435
|
+
PlayerStatus2["BUFFERING"] = "buffering";
|
|
436
|
+
PlayerStatus2["ERROR"] = "error";
|
|
437
|
+
return PlayerStatus2;
|
|
438
|
+
})(PlayerStatus || {});
|
|
439
|
+
var DEFAULT_PLAYER_CONFIG = {
|
|
440
|
+
autoplay: true,
|
|
441
|
+
defaultVolume: 1,
|
|
442
|
+
defaultMuted: true,
|
|
443
|
+
loop: true,
|
|
444
|
+
watchTimeThreshold: 3,
|
|
445
|
+
completionThreshold: 90
|
|
446
|
+
};
|
|
447
|
+
var DEFAULT_CIRCUIT_BREAKER_CONFIG = {
|
|
448
|
+
consecutiveErrorThreshold: 3,
|
|
449
|
+
resetTimeoutMs: 3e4,
|
|
450
|
+
// 30 seconds
|
|
451
|
+
successThreshold: 1,
|
|
452
|
+
enabled: true
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// src/player/state-machine.ts
|
|
456
|
+
var VALID_TRANSITIONS = {
|
|
457
|
+
["idle" /* IDLE */]: ["loading" /* LOADING */],
|
|
458
|
+
["loading" /* LOADING */]: ["playing" /* PLAYING */, "error" /* ERROR */, "idle" /* IDLE */],
|
|
459
|
+
["playing" /* PLAYING */]: [
|
|
460
|
+
"paused" /* PAUSED */,
|
|
461
|
+
"buffering" /* BUFFERING */,
|
|
462
|
+
"error" /* ERROR */,
|
|
463
|
+
"idle" /* IDLE */
|
|
464
|
+
],
|
|
465
|
+
["paused" /* PAUSED */]: ["playing" /* PLAYING */, "idle" /* IDLE */, "loading" /* LOADING */],
|
|
466
|
+
["buffering" /* BUFFERING */]: ["playing" /* PLAYING */, "error" /* ERROR */, "idle" /* IDLE */],
|
|
467
|
+
["error" /* ERROR */]: ["idle" /* IDLE */, "loading" /* LOADING */]
|
|
468
|
+
};
|
|
469
|
+
function isValidTransition(from, to) {
|
|
470
|
+
if (from === to) return true;
|
|
471
|
+
const validTargets = VALID_TRANSITIONS[from];
|
|
472
|
+
return validTargets.includes(to);
|
|
473
|
+
}
|
|
474
|
+
function getInvalidTransitionReason(from, to) {
|
|
475
|
+
const validTargets = VALID_TRANSITIONS[from];
|
|
476
|
+
return `Cannot transition from ${from} to ${to}. Valid transitions from ${from}: [${validTargets.join(", ")}]`;
|
|
477
|
+
}
|
|
478
|
+
function isActiveState(status) {
|
|
479
|
+
return status === "playing" /* PLAYING */ || status === "paused" /* PAUSED */ || status === "buffering" /* BUFFERING */;
|
|
480
|
+
}
|
|
481
|
+
function canPlay(status) {
|
|
482
|
+
return status === "paused" /* PAUSED */ || status === "loading" /* LOADING */;
|
|
483
|
+
}
|
|
484
|
+
function canPause(status) {
|
|
485
|
+
return status === "playing" /* PLAYING */ || status === "buffering" /* BUFFERING */;
|
|
486
|
+
}
|
|
487
|
+
function canSeek(status) {
|
|
488
|
+
return isActiveState(status);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/player/PlayerEngine.ts
|
|
492
|
+
var createInitialCircuitBreakerState = () => ({
|
|
493
|
+
state: "closed" /* CLOSED */,
|
|
494
|
+
consecutiveErrors: 0,
|
|
495
|
+
halfOpenSuccesses: 0,
|
|
496
|
+
openedAt: null,
|
|
497
|
+
lastError: null
|
|
498
|
+
});
|
|
499
|
+
var createInitialState2 = (config) => ({
|
|
500
|
+
status: "idle" /* IDLE */,
|
|
501
|
+
currentVideo: null,
|
|
502
|
+
currentTime: 0,
|
|
503
|
+
duration: 0,
|
|
504
|
+
buffered: 0,
|
|
505
|
+
volume: config.defaultVolume,
|
|
506
|
+
muted: config.defaultMuted,
|
|
507
|
+
playbackRate: 1,
|
|
508
|
+
loopCount: 0,
|
|
509
|
+
watchTime: 0,
|
|
510
|
+
error: null,
|
|
511
|
+
ended: false
|
|
512
|
+
});
|
|
513
|
+
var PlayerEngine = class {
|
|
514
|
+
constructor(config = {}) {
|
|
515
|
+
/** Event listeners */
|
|
516
|
+
this.eventListeners = /* @__PURE__ */ new Set();
|
|
517
|
+
/** Watch time tracking interval */
|
|
518
|
+
this.watchTimeInterval = null;
|
|
519
|
+
/** Last status for tracking transitions */
|
|
520
|
+
this.lastStatus = "idle" /* IDLE */;
|
|
521
|
+
/** Circuit Breaker state */
|
|
522
|
+
this.circuitBreaker = createInitialCircuitBreakerState();
|
|
523
|
+
/** Circuit Breaker reset timer */
|
|
524
|
+
this.circuitResetTimer = null;
|
|
525
|
+
const { analytics, logger, circuitBreaker, ...restConfig } = config;
|
|
526
|
+
this.config = { ...DEFAULT_PLAYER_CONFIG, ...restConfig };
|
|
527
|
+
this.circuitBreakerConfig = { ...DEFAULT_CIRCUIT_BREAKER_CONFIG, ...circuitBreaker };
|
|
528
|
+
this.analytics = analytics;
|
|
529
|
+
this.logger = logger;
|
|
530
|
+
this.store = createStore(() => createInitialState2(this.config));
|
|
531
|
+
this.store.subscribe((state) => {
|
|
532
|
+
if (state.status !== this.lastStatus) {
|
|
533
|
+
this.emitEvent({
|
|
534
|
+
type: "statusChange",
|
|
535
|
+
status: state.status,
|
|
536
|
+
previousStatus: this.lastStatus
|
|
537
|
+
});
|
|
538
|
+
this.lastStatus = state.status;
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
// ═══════════════════════════════════════════════════════════════
|
|
543
|
+
// PUBLIC API - PLAYBACK CONTROL
|
|
544
|
+
// ═══════════════════════════════════════════════════════════════
|
|
545
|
+
/**
|
|
546
|
+
* Load a video and prepare for playback
|
|
547
|
+
*
|
|
548
|
+
* @returns true if load was initiated, false if rejected (circuit open or invalid state)
|
|
549
|
+
*/
|
|
550
|
+
load(video) {
|
|
551
|
+
if (!this.checkCircuitBreaker()) {
|
|
552
|
+
this.logger?.warn("[PlayerEngine] Load rejected - circuit breaker is OPEN");
|
|
553
|
+
this.emitEvent({ type: "loadRejected", reason: "circuit_open" });
|
|
554
|
+
if (this.circuitBreaker.lastError) {
|
|
555
|
+
this.store.setState({ error: this.circuitBreaker.lastError });
|
|
556
|
+
this.transitionTo("error" /* ERROR */);
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
const currentStatus = this.store.getState().status;
|
|
561
|
+
if (currentStatus !== "idle" /* IDLE */ && currentStatus !== "error" /* ERROR */ && currentStatus !== "paused" /* PAUSED */) {
|
|
562
|
+
if (!this.transitionTo("idle" /* IDLE */)) {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (!this.transitionTo("loading" /* LOADING */)) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
this.store.setState({
|
|
570
|
+
currentVideo: video,
|
|
571
|
+
currentTime: 0,
|
|
572
|
+
duration: video.duration,
|
|
573
|
+
loopCount: 0,
|
|
574
|
+
watchTime: 0,
|
|
575
|
+
error: null,
|
|
576
|
+
ended: false
|
|
577
|
+
});
|
|
578
|
+
this.emitEvent({ type: "videoChange", video });
|
|
579
|
+
this.logger?.debug(`[PlayerEngine] Loaded video: ${video.id}`);
|
|
580
|
+
if (this.config.autoplay) {
|
|
581
|
+
this.onReady();
|
|
582
|
+
}
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Called when video is ready to play (after loading)
|
|
587
|
+
*/
|
|
588
|
+
onReady() {
|
|
589
|
+
const state = this.store.getState();
|
|
590
|
+
if (state.status !== "loading" /* LOADING */) {
|
|
591
|
+
this.logger?.warn(`[PlayerEngine] onReady called but status is ${state.status}, not LOADING`);
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
if (!this.transitionTo("playing" /* PLAYING */)) {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
this.recordCircuitBreakerSuccess();
|
|
598
|
+
this.startWatchTimeTracking();
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Start or resume playback
|
|
603
|
+
*/
|
|
604
|
+
play() {
|
|
605
|
+
const state = this.store.getState();
|
|
606
|
+
if (!canPlay(state.status)) {
|
|
607
|
+
this.logger?.warn(`[PlayerEngine] Cannot play in status: ${state.status}`);
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
if (!this.transitionTo("playing" /* PLAYING */)) {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
this.startWatchTimeTracking();
|
|
614
|
+
this.logger?.debug("[PlayerEngine] Playback started");
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Pause playback
|
|
619
|
+
*/
|
|
620
|
+
pause() {
|
|
621
|
+
const state = this.store.getState();
|
|
622
|
+
if (!canPause(state.status)) {
|
|
623
|
+
this.logger?.warn(`[PlayerEngine] Cannot pause in status: ${state.status}`);
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
if (!this.transitionTo("paused" /* PAUSED */)) {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
this.stopWatchTimeTracking();
|
|
630
|
+
this.logger?.debug("[PlayerEngine] Playback paused");
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Seek to a specific time
|
|
635
|
+
*
|
|
636
|
+
* Emits a 'seek' event that VideoPlayer listens to
|
|
637
|
+
* to apply the seek to the actual video element.
|
|
638
|
+
*/
|
|
639
|
+
seek(time) {
|
|
640
|
+
const state = this.store.getState();
|
|
641
|
+
if (!canSeek(state.status)) {
|
|
642
|
+
this.logger?.warn(`[PlayerEngine] Cannot seek in status: ${state.status}`);
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
const clampedTime = Math.max(0, Math.min(time, state.duration));
|
|
646
|
+
this.store.setState({ currentTime: clampedTime, ended: false });
|
|
647
|
+
this.emitEvent({ type: "seek", time: clampedTime });
|
|
648
|
+
this.logger?.debug(`[PlayerEngine] Seeked to: ${clampedTime}s`);
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Set volume
|
|
653
|
+
*/
|
|
654
|
+
setVolume(volume) {
|
|
655
|
+
const clampedVolume = Math.max(0, Math.min(1, volume));
|
|
656
|
+
this.store.setState({ volume: clampedVolume });
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Toggle mute
|
|
660
|
+
*/
|
|
661
|
+
setMuted(muted) {
|
|
662
|
+
this.store.setState({ muted });
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Set playback rate
|
|
666
|
+
*/
|
|
667
|
+
setPlaybackRate(rate) {
|
|
668
|
+
const clampedRate = Math.max(0.25, Math.min(2, rate));
|
|
669
|
+
this.store.setState({ playbackRate: clampedRate });
|
|
670
|
+
}
|
|
671
|
+
// ═══════════════════════════════════════════════════════════════
|
|
672
|
+
// PUBLIC API - VIDEO ELEMENT EVENTS
|
|
673
|
+
// ═══════════════════════════════════════════════════════════════
|
|
674
|
+
/**
|
|
675
|
+
* Handle time update from video element
|
|
676
|
+
*/
|
|
677
|
+
onTimeUpdate(currentTime) {
|
|
678
|
+
const state = this.store.getState();
|
|
679
|
+
this.store.setState({ currentTime });
|
|
680
|
+
this.emitEvent({
|
|
681
|
+
type: "timeUpdate",
|
|
682
|
+
currentTime,
|
|
683
|
+
duration: state.duration
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Handle duration change from video element
|
|
688
|
+
*
|
|
689
|
+
* Called when video metadata is loaded and actual duration is available.
|
|
690
|
+
* This may differ from the API-provided duration in VideoItem.
|
|
691
|
+
*
|
|
692
|
+
* @param duration - Actual video duration in seconds from video element
|
|
693
|
+
*/
|
|
694
|
+
onDurationChange(duration) {
|
|
695
|
+
if (duration > 0 && Number.isFinite(duration)) {
|
|
696
|
+
this.store.setState({ duration });
|
|
697
|
+
this.logger?.debug(`[PlayerEngine] Duration updated: ${duration}s`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Handle buffering start
|
|
702
|
+
*/
|
|
703
|
+
onBuffering() {
|
|
704
|
+
const state = this.store.getState();
|
|
705
|
+
if (state.status === "playing" /* PLAYING */) {
|
|
706
|
+
this.stopWatchTimeTracking();
|
|
707
|
+
return this.transitionTo("buffering" /* BUFFERING */);
|
|
708
|
+
}
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Handle buffering end
|
|
713
|
+
*/
|
|
714
|
+
onBufferingEnd() {
|
|
715
|
+
const state = this.store.getState();
|
|
716
|
+
if (state.status === "buffering" /* BUFFERING */) {
|
|
717
|
+
this.startWatchTimeTracking();
|
|
718
|
+
return this.transitionTo("playing" /* PLAYING */);
|
|
719
|
+
}
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Handle buffer progress update
|
|
724
|
+
*/
|
|
725
|
+
onBufferProgress(bufferedPercent) {
|
|
726
|
+
this.store.setState({ buffered: Math.min(100, Math.max(0, bufferedPercent)) });
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Handle video ended
|
|
730
|
+
*/
|
|
731
|
+
onEnded() {
|
|
732
|
+
const state = this.store.getState();
|
|
733
|
+
const newLoopCount = state.loopCount + 1;
|
|
734
|
+
this.store.setState({
|
|
735
|
+
loopCount: newLoopCount,
|
|
736
|
+
ended: true
|
|
737
|
+
});
|
|
738
|
+
this.emitEvent({
|
|
739
|
+
type: "ended",
|
|
740
|
+
loopCount: newLoopCount,
|
|
741
|
+
watchTime: state.watchTime
|
|
742
|
+
});
|
|
743
|
+
this.trackCompletion(state, newLoopCount);
|
|
744
|
+
if (this.config.loop) {
|
|
745
|
+
this.store.setState({ currentTime: 0, ended: false });
|
|
746
|
+
} else {
|
|
747
|
+
this.stopWatchTimeTracking();
|
|
748
|
+
this.transitionTo("paused" /* PAUSED */);
|
|
749
|
+
}
|
|
750
|
+
this.logger?.debug("[PlayerEngine] Video ended", {
|
|
751
|
+
loopCount: newLoopCount,
|
|
752
|
+
watchTime: state.watchTime
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Handle video error
|
|
757
|
+
*/
|
|
758
|
+
onError(error, mediaError) {
|
|
759
|
+
this.stopWatchTimeTracking();
|
|
760
|
+
const playerError = this.categorizeError(error, mediaError);
|
|
761
|
+
this.store.setState({ error: playerError });
|
|
762
|
+
this.transitionTo("error" /* ERROR */);
|
|
763
|
+
if (playerError.recoverable) {
|
|
764
|
+
this.recordCircuitBreakerError(playerError);
|
|
765
|
+
}
|
|
766
|
+
this.emitEvent({ type: "error", error: playerError });
|
|
767
|
+
this.logger?.error(`[PlayerEngine] Video error: ${playerError.message}`, error);
|
|
768
|
+
}
|
|
769
|
+
// ═══════════════════════════════════════════════════════════════
|
|
770
|
+
// PUBLIC API - LIFECYCLE
|
|
771
|
+
// ═══════════════════════════════════════════════════════════════
|
|
772
|
+
/**
|
|
773
|
+
* Reset player to initial state
|
|
774
|
+
*/
|
|
775
|
+
reset() {
|
|
776
|
+
this.stopWatchTimeTracking();
|
|
777
|
+
this.store.setState(createInitialState2(this.config));
|
|
778
|
+
this.lastStatus = "idle" /* IDLE */;
|
|
779
|
+
this.emitEvent({ type: "videoChange", video: null });
|
|
780
|
+
this.logger?.debug("[PlayerEngine] Player reset");
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Destroy player and cleanup
|
|
784
|
+
*/
|
|
785
|
+
destroy() {
|
|
786
|
+
this.stopWatchTimeTracking();
|
|
787
|
+
this.clearCircuitResetTimer();
|
|
788
|
+
this.eventListeners.clear();
|
|
789
|
+
this.store.setState(createInitialState2(this.config));
|
|
790
|
+
this.circuitBreaker = createInitialCircuitBreakerState();
|
|
791
|
+
this.logger?.debug("[PlayerEngine] Player destroyed");
|
|
792
|
+
}
|
|
793
|
+
// ═══════════════════════════════════════════════════════════════
|
|
794
|
+
// PUBLIC API - EVENTS
|
|
795
|
+
// ═══════════════════════════════════════════════════════════════
|
|
796
|
+
/**
|
|
797
|
+
* Add event listener
|
|
798
|
+
*/
|
|
799
|
+
addEventListener(listener) {
|
|
800
|
+
this.eventListeners.add(listener);
|
|
801
|
+
return () => this.eventListeners.delete(listener);
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Remove event listener
|
|
805
|
+
*/
|
|
806
|
+
removeEventListener(listener) {
|
|
807
|
+
this.eventListeners.delete(listener);
|
|
808
|
+
}
|
|
809
|
+
// ═══════════════════════════════════════════════════════════════
|
|
810
|
+
// PUBLIC API - GETTERS
|
|
811
|
+
// ═══════════════════════════════════════════════════════════════
|
|
812
|
+
/**
|
|
813
|
+
* Get current status
|
|
814
|
+
*/
|
|
815
|
+
getStatus() {
|
|
816
|
+
return this.store.getState().status;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Get current video
|
|
820
|
+
*/
|
|
821
|
+
getCurrentVideo() {
|
|
822
|
+
return this.store.getState().currentVideo;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Check if playing
|
|
826
|
+
*/
|
|
827
|
+
isPlaying() {
|
|
828
|
+
return this.store.getState().status === "playing" /* PLAYING */;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Check if paused
|
|
832
|
+
*/
|
|
833
|
+
isPaused() {
|
|
834
|
+
return this.store.getState().status === "paused" /* PAUSED */;
|
|
835
|
+
}
|
|
836
|
+
// ═══════════════════════════════════════════════════════════════
|
|
837
|
+
// PRIVATE METHODS
|
|
838
|
+
// ═══════════════════════════════════════════════════════════════
|
|
839
|
+
/**
|
|
840
|
+
* Attempt to transition to a new state
|
|
841
|
+
*/
|
|
842
|
+
transitionTo(newStatus) {
|
|
843
|
+
const currentStatus = this.store.getState().status;
|
|
844
|
+
if (!isValidTransition(currentStatus, newStatus)) {
|
|
845
|
+
const reason = getInvalidTransitionReason(currentStatus, newStatus);
|
|
846
|
+
this.logger?.warn(`[PlayerEngine] ${reason}`);
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
this.store.setState({ status: newStatus });
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Emit event to all listeners
|
|
854
|
+
*/
|
|
855
|
+
emitEvent(event) {
|
|
856
|
+
for (const listener of this.eventListeners) {
|
|
857
|
+
try {
|
|
858
|
+
listener(event);
|
|
859
|
+
} catch (err) {
|
|
860
|
+
this.logger?.error(
|
|
861
|
+
"[PlayerEngine] Event listener error",
|
|
862
|
+
err instanceof Error ? err : new Error(String(err))
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Start watch time tracking
|
|
869
|
+
*/
|
|
870
|
+
startWatchTimeTracking() {
|
|
871
|
+
if (this.watchTimeInterval) return;
|
|
872
|
+
this.watchTimeInterval = setInterval(() => {
|
|
873
|
+
const state = this.store.getState();
|
|
874
|
+
if (state.status === "playing" /* PLAYING */) {
|
|
875
|
+
this.store.setState({ watchTime: state.watchTime + 1 });
|
|
876
|
+
}
|
|
877
|
+
}, 1e3);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Stop watch time tracking
|
|
881
|
+
*/
|
|
882
|
+
stopWatchTimeTracking() {
|
|
883
|
+
if (this.watchTimeInterval) {
|
|
884
|
+
clearInterval(this.watchTimeInterval);
|
|
885
|
+
this.watchTimeInterval = null;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Track completion analytics
|
|
890
|
+
*/
|
|
891
|
+
trackCompletion(state, loopCount) {
|
|
892
|
+
if (!this.analytics || !state.currentVideo) return;
|
|
893
|
+
const watchPercentage = state.duration > 0 ? state.watchTime / state.duration * 100 : 0;
|
|
894
|
+
if (state.watchTime >= this.config.watchTimeThreshold) {
|
|
895
|
+
this.analytics.trackViewDuration(state.currentVideo.id, state.watchTime, state.duration);
|
|
896
|
+
}
|
|
897
|
+
if (watchPercentage >= this.config.completionThreshold) {
|
|
898
|
+
this.analytics.trackCompletion(state.currentVideo.id, state.watchTime, loopCount);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Categorize media error
|
|
903
|
+
*/
|
|
904
|
+
categorizeError(error, mediaError) {
|
|
905
|
+
if (mediaError) {
|
|
906
|
+
switch (mediaError.code) {
|
|
907
|
+
case MediaError.MEDIA_ERR_ABORTED:
|
|
908
|
+
return {
|
|
909
|
+
message: "Video playback was aborted.",
|
|
910
|
+
code: "MEDIA_ERROR",
|
|
911
|
+
recoverable: true,
|
|
912
|
+
originalError: error
|
|
913
|
+
};
|
|
914
|
+
case MediaError.MEDIA_ERR_NETWORK:
|
|
915
|
+
return {
|
|
916
|
+
message: "Network error while loading video.",
|
|
917
|
+
code: "NETWORK_ERROR",
|
|
918
|
+
recoverable: true,
|
|
919
|
+
originalError: error
|
|
920
|
+
};
|
|
921
|
+
case MediaError.MEDIA_ERR_DECODE:
|
|
922
|
+
return {
|
|
923
|
+
message: "Video decoding error.",
|
|
924
|
+
code: "DECODE_ERROR",
|
|
925
|
+
recoverable: false,
|
|
926
|
+
originalError: error
|
|
927
|
+
};
|
|
928
|
+
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
|
929
|
+
return {
|
|
930
|
+
message: "Video format not supported.",
|
|
931
|
+
code: "NOT_SUPPORTED",
|
|
932
|
+
recoverable: false,
|
|
933
|
+
originalError: error
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const lowerMessage = error.message.toLowerCase();
|
|
938
|
+
if (lowerMessage.includes("network")) {
|
|
939
|
+
return {
|
|
940
|
+
message: "Network error occurred.",
|
|
941
|
+
code: "NETWORK_ERROR",
|
|
942
|
+
recoverable: true,
|
|
943
|
+
originalError: error
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
message: error.message || "An unknown error occurred.",
|
|
948
|
+
code: "UNKNOWN",
|
|
949
|
+
recoverable: false,
|
|
950
|
+
originalError: error
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
// ═══════════════════════════════════════════════════════════════
|
|
954
|
+
// PUBLIC API - CIRCUIT BREAKER
|
|
955
|
+
// ═══════════════════════════════════════════════════════════════
|
|
956
|
+
/**
|
|
957
|
+
* Get current circuit breaker state
|
|
958
|
+
*/
|
|
959
|
+
getCircuitBreakerState() {
|
|
960
|
+
return { ...this.circuitBreaker };
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Check if circuit breaker is open (blocking loads)
|
|
964
|
+
*/
|
|
965
|
+
isCircuitOpen() {
|
|
966
|
+
return this.circuitBreaker.state === "open" /* OPEN */;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Manually reset circuit breaker to CLOSED state
|
|
970
|
+
* Useful for user-triggered retry after showing error UI
|
|
971
|
+
*/
|
|
972
|
+
resetCircuitBreaker() {
|
|
973
|
+
this.clearCircuitResetTimer();
|
|
974
|
+
this.circuitBreaker = createInitialCircuitBreakerState();
|
|
975
|
+
this.emitEvent({ type: "circuitClosed", reason: "manual" });
|
|
976
|
+
this.logger?.debug("[PlayerEngine] Circuit breaker manually reset");
|
|
977
|
+
}
|
|
978
|
+
// ═══════════════════════════════════════════════════════════════
|
|
979
|
+
// PRIVATE METHODS - CIRCUIT BREAKER
|
|
980
|
+
// ═══════════════════════════════════════════════════════════════
|
|
981
|
+
/**
|
|
982
|
+
* Check if circuit breaker allows load operation
|
|
983
|
+
* Also handles OPEN → HALF_OPEN transition based on timeout
|
|
984
|
+
*/
|
|
985
|
+
checkCircuitBreaker() {
|
|
986
|
+
if (!this.circuitBreakerConfig.enabled) {
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
const { state, openedAt } = this.circuitBreaker;
|
|
990
|
+
const { resetTimeoutMs } = this.circuitBreakerConfig;
|
|
991
|
+
switch (state) {
|
|
992
|
+
case "closed" /* CLOSED */:
|
|
993
|
+
return true;
|
|
994
|
+
case "open" /* OPEN */:
|
|
995
|
+
if (openedAt && Date.now() - openedAt >= resetTimeoutMs) {
|
|
996
|
+
this.transitionToHalfOpen();
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
999
|
+
return false;
|
|
1000
|
+
// Still in OPEN state, reject
|
|
1001
|
+
case "half_open" /* HALF_OPEN */:
|
|
1002
|
+
return true;
|
|
1003
|
+
default:
|
|
1004
|
+
return true;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Record a recoverable error for circuit breaker tracking
|
|
1009
|
+
*/
|
|
1010
|
+
recordCircuitBreakerError(error) {
|
|
1011
|
+
if (!this.circuitBreakerConfig.enabled) {
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const { consecutiveErrorThreshold } = this.circuitBreakerConfig;
|
|
1015
|
+
const newConsecutiveErrors = this.circuitBreaker.consecutiveErrors + 1;
|
|
1016
|
+
this.circuitBreaker.consecutiveErrors = newConsecutiveErrors;
|
|
1017
|
+
this.circuitBreaker.lastError = error;
|
|
1018
|
+
this.logger?.debug("[PlayerEngine] Circuit breaker recorded error", {
|
|
1019
|
+
consecutiveErrors: newConsecutiveErrors,
|
|
1020
|
+
threshold: consecutiveErrorThreshold
|
|
1021
|
+
});
|
|
1022
|
+
if (newConsecutiveErrors >= consecutiveErrorThreshold) {
|
|
1023
|
+
this.tripCircuit(error);
|
|
1024
|
+
}
|
|
1025
|
+
if (this.circuitBreaker.state === "half_open" /* HALF_OPEN */) {
|
|
1026
|
+
this.tripCircuit(error);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Record a successful load for circuit breaker tracking
|
|
1031
|
+
*/
|
|
1032
|
+
recordCircuitBreakerSuccess() {
|
|
1033
|
+
if (!this.circuitBreakerConfig.enabled) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const { state } = this.circuitBreaker;
|
|
1037
|
+
const { successThreshold } = this.circuitBreakerConfig;
|
|
1038
|
+
this.circuitBreaker.consecutiveErrors = 0;
|
|
1039
|
+
if (state === "half_open" /* HALF_OPEN */) {
|
|
1040
|
+
this.circuitBreaker.halfOpenSuccesses += 1;
|
|
1041
|
+
this.logger?.debug("[PlayerEngine] Circuit breaker half-open success", {
|
|
1042
|
+
halfOpenSuccesses: this.circuitBreaker.halfOpenSuccesses,
|
|
1043
|
+
threshold: successThreshold
|
|
1044
|
+
});
|
|
1045
|
+
if (this.circuitBreaker.halfOpenSuccesses >= successThreshold) {
|
|
1046
|
+
this.closeCircuit();
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Trip the circuit breaker (CLOSED/HALF_OPEN → OPEN)
|
|
1052
|
+
*/
|
|
1053
|
+
tripCircuit(error) {
|
|
1054
|
+
this.clearCircuitResetTimer();
|
|
1055
|
+
this.circuitBreaker.state = "open" /* OPEN */;
|
|
1056
|
+
this.circuitBreaker.openedAt = Date.now();
|
|
1057
|
+
this.circuitBreaker.halfOpenSuccesses = 0;
|
|
1058
|
+
this.emitEvent({
|
|
1059
|
+
type: "circuitOpened",
|
|
1060
|
+
consecutiveErrors: this.circuitBreaker.consecutiveErrors,
|
|
1061
|
+
lastError: error
|
|
1062
|
+
});
|
|
1063
|
+
this.logger?.warn("[PlayerEngine] Circuit breaker OPENED", {
|
|
1064
|
+
consecutiveErrors: this.circuitBreaker.consecutiveErrors,
|
|
1065
|
+
error: error.message
|
|
1066
|
+
});
|
|
1067
|
+
this.scheduleHalfOpenTransition();
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Transition from OPEN to HALF_OPEN
|
|
1071
|
+
*/
|
|
1072
|
+
transitionToHalfOpen() {
|
|
1073
|
+
this.circuitBreaker.state = "half_open" /* HALF_OPEN */;
|
|
1074
|
+
this.circuitBreaker.halfOpenSuccesses = 0;
|
|
1075
|
+
this.emitEvent({ type: "circuitHalfOpen" });
|
|
1076
|
+
this.logger?.debug("[PlayerEngine] Circuit breaker transitioned to HALF_OPEN");
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Close the circuit breaker (HALF_OPEN → CLOSED)
|
|
1080
|
+
*/
|
|
1081
|
+
closeCircuit() {
|
|
1082
|
+
this.clearCircuitResetTimer();
|
|
1083
|
+
this.circuitBreaker = createInitialCircuitBreakerState();
|
|
1084
|
+
this.emitEvent({ type: "circuitClosed", reason: "success" });
|
|
1085
|
+
this.logger?.debug("[PlayerEngine] Circuit breaker CLOSED after successful recovery");
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Schedule automatic transition from OPEN to HALF_OPEN
|
|
1089
|
+
*/
|
|
1090
|
+
scheduleHalfOpenTransition() {
|
|
1091
|
+
this.clearCircuitResetTimer();
|
|
1092
|
+
this.circuitResetTimer = setTimeout(() => {
|
|
1093
|
+
if (this.circuitBreaker.state === "open" /* OPEN */) {
|
|
1094
|
+
this.transitionToHalfOpen();
|
|
1095
|
+
}
|
|
1096
|
+
}, this.circuitBreakerConfig.resetTimeoutMs);
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Clear circuit reset timer
|
|
1100
|
+
*/
|
|
1101
|
+
clearCircuitResetTimer() {
|
|
1102
|
+
if (this.circuitResetTimer) {
|
|
1103
|
+
clearTimeout(this.circuitResetTimer);
|
|
1104
|
+
this.circuitResetTimer = null;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
// src/lifecycle/types.ts
|
|
1110
|
+
var DEFAULT_LIFECYCLE_CONFIG = {
|
|
1111
|
+
snapshotExpiryMs: 24 * 60 * 60 * 1e3,
|
|
1112
|
+
// 24 hours
|
|
1113
|
+
revalidationThresholdMs: 5 * 60 * 1e3,
|
|
1114
|
+
// 5 minutes
|
|
1115
|
+
autoSaveOnHidden: true,
|
|
1116
|
+
restorePlaybackPosition: false,
|
|
1117
|
+
// User must opt-in for playback position restore
|
|
1118
|
+
version: "1.0.0"
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
// src/lifecycle/LifecycleManager.ts
|
|
1122
|
+
var createInitialState3 = () => ({
|
|
1123
|
+
isInitialized: false,
|
|
1124
|
+
isSaving: false,
|
|
1125
|
+
isRestoring: false,
|
|
1126
|
+
lastSavedAt: null,
|
|
1127
|
+
lastRestoredAt: null,
|
|
1128
|
+
needsRevalidation: false,
|
|
1129
|
+
visibilityState: typeof document !== "undefined" ? document.visibilityState : "visible"
|
|
1130
|
+
});
|
|
1131
|
+
var LifecycleManager = class {
|
|
1132
|
+
constructor(config = {}) {
|
|
1133
|
+
/** Event listeners */
|
|
1134
|
+
this.eventListeners = /* @__PURE__ */ new Set();
|
|
1135
|
+
const { storage, logger, ...restConfig } = config;
|
|
1136
|
+
this.config = { ...DEFAULT_LIFECYCLE_CONFIG, ...restConfig };
|
|
1137
|
+
this.storage = storage;
|
|
1138
|
+
this.logger = logger;
|
|
1139
|
+
this.store = createStore(createInitialState3);
|
|
1140
|
+
}
|
|
1141
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1142
|
+
// PUBLIC API - LIFECYCLE
|
|
1143
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1144
|
+
/**
|
|
1145
|
+
* Initialize lifecycle manager and attempt to restore session
|
|
1146
|
+
*/
|
|
1147
|
+
async initialize() {
|
|
1148
|
+
if (this.store.getState().isInitialized) {
|
|
1149
|
+
this.logger?.warn("[LifecycleManager] Already initialized");
|
|
1150
|
+
return {
|
|
1151
|
+
success: false,
|
|
1152
|
+
snapshot: null,
|
|
1153
|
+
needsRevalidation: false,
|
|
1154
|
+
reason: "error"
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
if (this.config.autoSaveOnHidden && typeof document !== "undefined") {
|
|
1158
|
+
this.setupVisibilityListener();
|
|
1159
|
+
}
|
|
1160
|
+
this.store.setState({ isInitialized: true });
|
|
1161
|
+
this.logger?.debug("[LifecycleManager] Initialized");
|
|
1162
|
+
return this.restoreSession();
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Destroy lifecycle manager and cleanup
|
|
1166
|
+
*/
|
|
1167
|
+
destroy() {
|
|
1168
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
1169
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
1170
|
+
this.visibilityHandler = void 0;
|
|
1171
|
+
}
|
|
1172
|
+
this.eventListeners.clear();
|
|
1173
|
+
this.store.setState(createInitialState3());
|
|
1174
|
+
this.logger?.debug("[LifecycleManager] Destroyed");
|
|
1175
|
+
}
|
|
1176
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1177
|
+
// PUBLIC API - SESSION MANAGEMENT
|
|
1178
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1179
|
+
/**
|
|
1180
|
+
* Restore session from storage
|
|
1181
|
+
*/
|
|
1182
|
+
async restoreSession() {
|
|
1183
|
+
if (!this.storage) {
|
|
1184
|
+
this.logger?.debug("[LifecycleManager] No storage adapter, skipping restore");
|
|
1185
|
+
return {
|
|
1186
|
+
success: false,
|
|
1187
|
+
snapshot: null,
|
|
1188
|
+
needsRevalidation: false,
|
|
1189
|
+
reason: "no_snapshot"
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
this.store.setState({ isRestoring: true });
|
|
1193
|
+
this.emitEvent({ type: "restoreStart" });
|
|
1194
|
+
try {
|
|
1195
|
+
const snapshot = await this.storage.loadSnapshot();
|
|
1196
|
+
if (!snapshot) {
|
|
1197
|
+
this.logger?.debug("[LifecycleManager] No snapshot found");
|
|
1198
|
+
const result2 = {
|
|
1199
|
+
success: false,
|
|
1200
|
+
snapshot: null,
|
|
1201
|
+
needsRevalidation: false,
|
|
1202
|
+
reason: "no_snapshot"
|
|
1203
|
+
};
|
|
1204
|
+
this.store.setState({ isRestoring: false });
|
|
1205
|
+
this.emitEvent({ type: "restoreComplete", result: result2 });
|
|
1206
|
+
return result2;
|
|
1207
|
+
}
|
|
1208
|
+
const validationResult = this.validateSnapshot(snapshot);
|
|
1209
|
+
if (!validationResult.valid) {
|
|
1210
|
+
this.logger?.debug(`[LifecycleManager] Invalid snapshot: ${validationResult.reason}`);
|
|
1211
|
+
await this.clearSnapshot();
|
|
1212
|
+
const result2 = {
|
|
1213
|
+
success: false,
|
|
1214
|
+
snapshot: null,
|
|
1215
|
+
needsRevalidation: false,
|
|
1216
|
+
reason: validationResult.reason
|
|
1217
|
+
};
|
|
1218
|
+
this.store.setState({ isRestoring: false });
|
|
1219
|
+
this.emitEvent({ type: "restoreComplete", result: result2 });
|
|
1220
|
+
return result2;
|
|
1221
|
+
}
|
|
1222
|
+
const needsRevalidation = this.isSnapshotStale(snapshot);
|
|
1223
|
+
const validSnapshot = snapshot;
|
|
1224
|
+
const result = {
|
|
1225
|
+
success: true,
|
|
1226
|
+
snapshot: validSnapshot,
|
|
1227
|
+
needsRevalidation
|
|
1228
|
+
};
|
|
1229
|
+
if (this.config.restorePlaybackPosition) {
|
|
1230
|
+
if (validSnapshot.playbackTime !== void 0) {
|
|
1231
|
+
result.playbackTime = validSnapshot.playbackTime;
|
|
1232
|
+
}
|
|
1233
|
+
if (validSnapshot.currentVideoId !== void 0) {
|
|
1234
|
+
result.currentVideoId = validSnapshot.currentVideoId;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
this.store.setState({
|
|
1238
|
+
isRestoring: false,
|
|
1239
|
+
lastRestoredAt: Date.now(),
|
|
1240
|
+
needsRevalidation
|
|
1241
|
+
});
|
|
1242
|
+
this.emitEvent({ type: "restoreComplete", result });
|
|
1243
|
+
this.logger?.debug("[LifecycleManager] Session restored", {
|
|
1244
|
+
itemCount: validSnapshot.items.length,
|
|
1245
|
+
needsRevalidation,
|
|
1246
|
+
hasPlaybackTime: this.config.restorePlaybackPosition && validSnapshot.playbackTime !== void 0
|
|
1247
|
+
});
|
|
1248
|
+
return result;
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
this.logger?.error(
|
|
1251
|
+
"[LifecycleManager] Restore failed",
|
|
1252
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1253
|
+
);
|
|
1254
|
+
const result = {
|
|
1255
|
+
success: false,
|
|
1256
|
+
snapshot: null,
|
|
1257
|
+
needsRevalidation: false,
|
|
1258
|
+
reason: "error"
|
|
1259
|
+
};
|
|
1260
|
+
this.store.setState({ isRestoring: false });
|
|
1261
|
+
this.emitEvent({ type: "restoreComplete", result });
|
|
1262
|
+
return result;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Save session snapshot to storage
|
|
1267
|
+
*
|
|
1268
|
+
* @param data - Snapshot data to save
|
|
1269
|
+
* @param data.playbackTime - Current video playback position (only saved if restorePlaybackPosition config is enabled)
|
|
1270
|
+
* @param data.currentVideoId - Current video ID (only saved if restorePlaybackPosition config is enabled)
|
|
1271
|
+
*/
|
|
1272
|
+
async saveSnapshot(data) {
|
|
1273
|
+
if (!this.storage) {
|
|
1274
|
+
this.logger?.debug("[LifecycleManager] No storage adapter, skipping save");
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
if (data.items.length === 0) {
|
|
1278
|
+
this.logger?.debug("[LifecycleManager] No items to save, skipping");
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|
|
1281
|
+
this.store.setState({ isSaving: true });
|
|
1282
|
+
this.emitEvent({ type: "saveStart" });
|
|
1283
|
+
try {
|
|
1284
|
+
const snapshot = {
|
|
1285
|
+
items: data.items,
|
|
1286
|
+
cursor: data.cursor,
|
|
1287
|
+
focusedIndex: data.focusedIndex,
|
|
1288
|
+
scrollPosition: data.scrollPosition,
|
|
1289
|
+
savedAt: Date.now(),
|
|
1290
|
+
version: this.config.version
|
|
1291
|
+
};
|
|
1292
|
+
if (this.config.restorePlaybackPosition) {
|
|
1293
|
+
if (data.playbackTime !== void 0) {
|
|
1294
|
+
snapshot.playbackTime = data.playbackTime;
|
|
1295
|
+
}
|
|
1296
|
+
if (data.currentVideoId !== void 0) {
|
|
1297
|
+
snapshot.currentVideoId = data.currentVideoId;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
await this.storage.saveSnapshot(snapshot);
|
|
1301
|
+
const timestamp = Date.now();
|
|
1302
|
+
this.store.setState({
|
|
1303
|
+
isSaving: false,
|
|
1304
|
+
lastSavedAt: timestamp
|
|
1305
|
+
});
|
|
1306
|
+
this.emitEvent({ type: "saveComplete", timestamp });
|
|
1307
|
+
this.logger?.debug("[LifecycleManager] Snapshot saved", {
|
|
1308
|
+
itemCount: data.items.length,
|
|
1309
|
+
hasPlaybackTime: this.config.restorePlaybackPosition && data.playbackTime !== void 0
|
|
1310
|
+
});
|
|
1311
|
+
return true;
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
this.logger?.error(
|
|
1314
|
+
"[LifecycleManager] Save failed",
|
|
1315
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1316
|
+
);
|
|
1317
|
+
this.store.setState({ isSaving: false });
|
|
1318
|
+
this.emitEvent({
|
|
1319
|
+
type: "saveFailed",
|
|
1320
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1321
|
+
});
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Clear saved snapshot
|
|
1327
|
+
*/
|
|
1328
|
+
async clearSnapshot() {
|
|
1329
|
+
if (!this.storage) return;
|
|
1330
|
+
try {
|
|
1331
|
+
await this.storage.clearSnapshot();
|
|
1332
|
+
this.logger?.debug("[LifecycleManager] Snapshot cleared");
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
this.logger?.error(
|
|
1335
|
+
"[LifecycleManager] Clear failed",
|
|
1336
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Mark that pending data should be saved (for use with debouncing)
|
|
1342
|
+
*
|
|
1343
|
+
* @param data - Data to save when flush is called
|
|
1344
|
+
*/
|
|
1345
|
+
setPendingSave(data) {
|
|
1346
|
+
this.pendingSaveData = data;
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Check if restorePlaybackPosition config is enabled
|
|
1350
|
+
* SDK can use this to decide whether to collect playbackTime
|
|
1351
|
+
*/
|
|
1352
|
+
isPlaybackPositionRestoreEnabled() {
|
|
1353
|
+
return this.config.restorePlaybackPosition;
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Flush pending save (called on visibility hidden)
|
|
1357
|
+
*/
|
|
1358
|
+
async flushPendingSave() {
|
|
1359
|
+
if (this.pendingSaveData) {
|
|
1360
|
+
await this.saveSnapshot(this.pendingSaveData);
|
|
1361
|
+
this.pendingSaveData = void 0;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1365
|
+
// PUBLIC API - VISIBILITY
|
|
1366
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1367
|
+
/**
|
|
1368
|
+
* Handle visibility state change
|
|
1369
|
+
*/
|
|
1370
|
+
onVisibilityChange(state) {
|
|
1371
|
+
const previousState = this.store.getState().visibilityState;
|
|
1372
|
+
if (state === previousState) return;
|
|
1373
|
+
this.store.setState({ visibilityState: state });
|
|
1374
|
+
this.emitEvent({ type: "visibilityChange", state });
|
|
1375
|
+
if (state === "hidden" && this.config.autoSaveOnHidden) {
|
|
1376
|
+
this.flushPendingSave();
|
|
1377
|
+
}
|
|
1378
|
+
this.logger?.debug("[LifecycleManager] Visibility changed", { state });
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Get current visibility state
|
|
1382
|
+
*/
|
|
1383
|
+
getVisibilityState() {
|
|
1384
|
+
return this.store.getState().visibilityState;
|
|
1385
|
+
}
|
|
1386
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1387
|
+
// PUBLIC API - EVENTS
|
|
1388
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1389
|
+
/**
|
|
1390
|
+
* Add event listener
|
|
1391
|
+
*/
|
|
1392
|
+
addEventListener(listener) {
|
|
1393
|
+
this.eventListeners.add(listener);
|
|
1394
|
+
return () => this.eventListeners.delete(listener);
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Remove event listener
|
|
1398
|
+
*/
|
|
1399
|
+
removeEventListener(listener) {
|
|
1400
|
+
this.eventListeners.delete(listener);
|
|
1401
|
+
}
|
|
1402
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1403
|
+
// PRIVATE METHODS
|
|
1404
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1405
|
+
/**
|
|
1406
|
+
* Setup visibility change listener
|
|
1407
|
+
*/
|
|
1408
|
+
setupVisibilityListener() {
|
|
1409
|
+
this.visibilityHandler = () => {
|
|
1410
|
+
this.onVisibilityChange(document.visibilityState);
|
|
1411
|
+
};
|
|
1412
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Validate snapshot data
|
|
1416
|
+
*/
|
|
1417
|
+
validateSnapshot(snapshot) {
|
|
1418
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
1419
|
+
return { valid: false, reason: "invalid" };
|
|
1420
|
+
}
|
|
1421
|
+
const s = snapshot;
|
|
1422
|
+
if (!Array.isArray(s.items)) {
|
|
1423
|
+
return { valid: false, reason: "invalid" };
|
|
1424
|
+
}
|
|
1425
|
+
if (typeof s.savedAt !== "number") {
|
|
1426
|
+
return { valid: false, reason: "invalid" };
|
|
1427
|
+
}
|
|
1428
|
+
if (Date.now() - s.savedAt > this.config.snapshotExpiryMs) {
|
|
1429
|
+
return { valid: false, reason: "expired" };
|
|
1430
|
+
}
|
|
1431
|
+
return { valid: true };
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Check if snapshot is stale (needs revalidation)
|
|
1435
|
+
*/
|
|
1436
|
+
isSnapshotStale(snapshot) {
|
|
1437
|
+
return Date.now() - snapshot.savedAt > this.config.revalidationThresholdMs;
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Emit event to all listeners
|
|
1441
|
+
*/
|
|
1442
|
+
emitEvent(event) {
|
|
1443
|
+
for (const listener of this.eventListeners) {
|
|
1444
|
+
try {
|
|
1445
|
+
listener(event);
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
this.logger?.error(
|
|
1448
|
+
"[LifecycleManager] Event listener error",
|
|
1449
|
+
err instanceof Error ? err : new Error(String(err))
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
// src/resource/allocation.ts
|
|
1457
|
+
function calculateWindowIndices(focusedIndex, totalItems, maxAllocations = 3) {
|
|
1458
|
+
if (totalItems === 0) return [];
|
|
1459
|
+
const indices = [];
|
|
1460
|
+
const clampedFocus = Math.max(0, Math.min(focusedIndex, totalItems - 1));
|
|
1461
|
+
indices.push(clampedFocus);
|
|
1462
|
+
if (clampedFocus > 0 && indices.length < maxAllocations) {
|
|
1463
|
+
indices.push(clampedFocus - 1);
|
|
1464
|
+
}
|
|
1465
|
+
if (clampedFocus < totalItems - 1 && indices.length < maxAllocations) {
|
|
1466
|
+
indices.push(clampedFocus + 1);
|
|
1467
|
+
}
|
|
1468
|
+
return indices.sort((a, b) => a - b);
|
|
1469
|
+
}
|
|
1470
|
+
function calculatePrefetchIndices(focusedIndex, totalItems, prefetchCount, windowIndices) {
|
|
1471
|
+
if (totalItems === 0 || prefetchCount === 0) return [];
|
|
1472
|
+
const windowSet = new Set(windowIndices);
|
|
1473
|
+
const prefetchIndices = [];
|
|
1474
|
+
for (let i = 1; i <= prefetchCount; i++) {
|
|
1475
|
+
const index = focusedIndex + i;
|
|
1476
|
+
if (index < totalItems && !windowSet.has(index)) {
|
|
1477
|
+
prefetchIndices.push(index);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return prefetchIndices;
|
|
1481
|
+
}
|
|
1482
|
+
function computeAllocationChanges(currentAllocations, desiredAllocations) {
|
|
1483
|
+
const desiredSet = new Set(desiredAllocations);
|
|
1484
|
+
const toUnmount = [];
|
|
1485
|
+
for (const index of currentAllocations) {
|
|
1486
|
+
if (!desiredSet.has(index)) {
|
|
1487
|
+
toUnmount.push(index);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
const toMount = [];
|
|
1491
|
+
for (const index of desiredAllocations) {
|
|
1492
|
+
if (!currentAllocations.has(index)) {
|
|
1493
|
+
toMount.push(index);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return {
|
|
1497
|
+
toMount: toMount.sort((a, b) => a - b),
|
|
1498
|
+
toUnmount: toUnmount.sort((a, b) => a - b),
|
|
1499
|
+
activeAllocations: desiredAllocations
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// src/resource/types.ts
|
|
1504
|
+
function mapNetworkType(type) {
|
|
1505
|
+
switch (type) {
|
|
1506
|
+
case "wifi":
|
|
1507
|
+
return "wifi";
|
|
1508
|
+
case "cellular":
|
|
1509
|
+
case "4g":
|
|
1510
|
+
case "3g":
|
|
1511
|
+
case "slow":
|
|
1512
|
+
return "cellular";
|
|
1513
|
+
case "offline":
|
|
1514
|
+
return "offline";
|
|
1515
|
+
default:
|
|
1516
|
+
return "wifi";
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
var DEFAULT_SCROLL_THRASHING_CONFIG = {
|
|
1520
|
+
windowMs: 1e3,
|
|
1521
|
+
maxChangesInWindow: 3,
|
|
1522
|
+
cooldownMs: 500
|
|
1523
|
+
};
|
|
1524
|
+
var DEFAULT_PREFETCH_CONFIG = {
|
|
1525
|
+
wifi: {
|
|
1526
|
+
posterCount: 5,
|
|
1527
|
+
videoSegmentCount: 2,
|
|
1528
|
+
prefetchVideo: true
|
|
1529
|
+
},
|
|
1530
|
+
cellular: {
|
|
1531
|
+
posterCount: 3,
|
|
1532
|
+
videoSegmentCount: 1,
|
|
1533
|
+
prefetchVideo: true
|
|
1534
|
+
},
|
|
1535
|
+
offline: {
|
|
1536
|
+
posterCount: 1,
|
|
1537
|
+
videoSegmentCount: 0,
|
|
1538
|
+
prefetchVideo: false
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
var DEFAULT_RESOURCE_CONFIG = {
|
|
1542
|
+
maxAllocations: 3,
|
|
1543
|
+
focusDebounceMs: 150,
|
|
1544
|
+
prefetch: DEFAULT_PREFETCH_CONFIG,
|
|
1545
|
+
scrollThrashing: DEFAULT_SCROLL_THRASHING_CONFIG
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
// src/resource/ResourceGovernor.ts
|
|
1549
|
+
var createInitialState4 = () => ({
|
|
1550
|
+
activeAllocations: /* @__PURE__ */ new Set(),
|
|
1551
|
+
preloadQueue: [],
|
|
1552
|
+
focusedIndex: 0,
|
|
1553
|
+
networkType: "wifi",
|
|
1554
|
+
totalItems: 0,
|
|
1555
|
+
isActive: false,
|
|
1556
|
+
isThrottled: false,
|
|
1557
|
+
preloadingIndices: /* @__PURE__ */ new Set()
|
|
1558
|
+
});
|
|
1559
|
+
var ResourceGovernor = class {
|
|
1560
|
+
constructor(config = {}) {
|
|
1561
|
+
/** Event listeners */
|
|
1562
|
+
this.eventListeners = /* @__PURE__ */ new Set();
|
|
1563
|
+
/** Focus debounce timer */
|
|
1564
|
+
this.focusDebounceTimer = null;
|
|
1565
|
+
/** Pending focused index (before debounce completes) */
|
|
1566
|
+
this.pendingFocusedIndex = null;
|
|
1567
|
+
/** Scroll thrashing detection - timestamps of recent focus changes */
|
|
1568
|
+
this.focusChangeTimestamps = [];
|
|
1569
|
+
/** Scroll thrashing cooldown timer */
|
|
1570
|
+
this.thrashingCooldownTimer = null;
|
|
1571
|
+
const {
|
|
1572
|
+
networkAdapter,
|
|
1573
|
+
videoLoader,
|
|
1574
|
+
posterLoader,
|
|
1575
|
+
logger,
|
|
1576
|
+
prefetch,
|
|
1577
|
+
scrollThrashing,
|
|
1578
|
+
...restConfig
|
|
1579
|
+
} = config;
|
|
1580
|
+
this.config = {
|
|
1581
|
+
...DEFAULT_RESOURCE_CONFIG,
|
|
1582
|
+
...restConfig,
|
|
1583
|
+
prefetch: {
|
|
1584
|
+
wifi: { ...DEFAULT_PREFETCH_CONFIG.wifi, ...prefetch?.wifi },
|
|
1585
|
+
cellular: { ...DEFAULT_PREFETCH_CONFIG.cellular, ...prefetch?.cellular },
|
|
1586
|
+
offline: { ...DEFAULT_PREFETCH_CONFIG.offline, ...prefetch?.offline }
|
|
1587
|
+
},
|
|
1588
|
+
scrollThrashing: {
|
|
1589
|
+
...DEFAULT_SCROLL_THRASHING_CONFIG,
|
|
1590
|
+
...scrollThrashing
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
this.networkAdapter = networkAdapter;
|
|
1594
|
+
this.videoLoader = videoLoader;
|
|
1595
|
+
this.posterLoader = posterLoader;
|
|
1596
|
+
this.logger = logger;
|
|
1597
|
+
this.store = createStore(createInitialState4);
|
|
1598
|
+
}
|
|
1599
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1600
|
+
// PUBLIC API - LIFECYCLE
|
|
1601
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1602
|
+
/**
|
|
1603
|
+
* Activate the resource governor
|
|
1604
|
+
* Starts network monitoring and performs initial allocation
|
|
1605
|
+
*/
|
|
1606
|
+
async activate() {
|
|
1607
|
+
if (this.store.getState().isActive) return;
|
|
1608
|
+
this.store.setState({ isActive: true });
|
|
1609
|
+
await this.initializeNetwork();
|
|
1610
|
+
this.performAllocation(this.store.getState().focusedIndex);
|
|
1611
|
+
this.logger?.debug("[ResourceGovernor] Activated");
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Deactivate the resource governor
|
|
1615
|
+
* Stops network monitoring and clears allocations
|
|
1616
|
+
*/
|
|
1617
|
+
deactivate() {
|
|
1618
|
+
if (!this.store.getState().isActive) return;
|
|
1619
|
+
if (this.focusDebounceTimer) {
|
|
1620
|
+
clearTimeout(this.focusDebounceTimer);
|
|
1621
|
+
this.focusDebounceTimer = null;
|
|
1622
|
+
}
|
|
1623
|
+
if (this.thrashingCooldownTimer) {
|
|
1624
|
+
clearTimeout(this.thrashingCooldownTimer);
|
|
1625
|
+
this.thrashingCooldownTimer = null;
|
|
1626
|
+
}
|
|
1627
|
+
this.focusChangeTimestamps = [];
|
|
1628
|
+
if (this.networkUnsubscribe) {
|
|
1629
|
+
this.networkUnsubscribe();
|
|
1630
|
+
this.networkUnsubscribe = void 0;
|
|
1631
|
+
}
|
|
1632
|
+
const state = this.store.getState();
|
|
1633
|
+
if (this.videoLoader) {
|
|
1634
|
+
for (const index of state.preloadingIndices) {
|
|
1635
|
+
const videoInfo = this.videoSourceGetter?.(index);
|
|
1636
|
+
if (videoInfo) {
|
|
1637
|
+
this.videoLoader.cancelPreload(videoInfo.id);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
if (state.activeAllocations.size > 0) {
|
|
1642
|
+
this.emitEvent({
|
|
1643
|
+
type: "allocationChange",
|
|
1644
|
+
toMount: [],
|
|
1645
|
+
toUnmount: [...state.activeAllocations]
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
this.store.setState(createInitialState4());
|
|
1649
|
+
this.logger?.debug("[ResourceGovernor] Deactivated");
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Destroy the resource governor
|
|
1653
|
+
*/
|
|
1654
|
+
destroy() {
|
|
1655
|
+
this.deactivate();
|
|
1656
|
+
this.eventListeners.clear();
|
|
1657
|
+
this.logger?.debug("[ResourceGovernor] Destroyed");
|
|
1658
|
+
}
|
|
1659
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1660
|
+
// PUBLIC API - FEED MANAGEMENT
|
|
1661
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1662
|
+
/**
|
|
1663
|
+
* Set total number of items in feed
|
|
1664
|
+
*/
|
|
1665
|
+
setTotalItems(count) {
|
|
1666
|
+
const state = this.store.getState();
|
|
1667
|
+
if (state.totalItems === count) return;
|
|
1668
|
+
this.store.setState({ totalItems: count });
|
|
1669
|
+
if (state.isActive) {
|
|
1670
|
+
this.performAllocation(state.focusedIndex);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Set focused index with debouncing
|
|
1675
|
+
* This is called during scroll/swipe
|
|
1676
|
+
*/
|
|
1677
|
+
setFocusedIndex(index) {
|
|
1678
|
+
const state = this.store.getState();
|
|
1679
|
+
const clampedIndex = Math.max(0, Math.min(index, state.totalItems - 1));
|
|
1680
|
+
if (clampedIndex === state.focusedIndex && this.pendingFocusedIndex === null) {
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
this.pendingFocusedIndex = clampedIndex;
|
|
1684
|
+
this.trackFocusChange();
|
|
1685
|
+
if (this.focusDebounceTimer) {
|
|
1686
|
+
clearTimeout(this.focusDebounceTimer);
|
|
1687
|
+
}
|
|
1688
|
+
this.focusDebounceTimer = setTimeout(() => {
|
|
1689
|
+
this.focusDebounceTimer = null;
|
|
1690
|
+
if (this.pendingFocusedIndex !== null) {
|
|
1691
|
+
const previousIndex = this.store.getState().focusedIndex;
|
|
1692
|
+
const newIndex = this.pendingFocusedIndex;
|
|
1693
|
+
this.pendingFocusedIndex = null;
|
|
1694
|
+
this.store.setState({ focusedIndex: newIndex });
|
|
1695
|
+
this.emitEvent({
|
|
1696
|
+
type: "focusChange",
|
|
1697
|
+
index: newIndex,
|
|
1698
|
+
previousIndex
|
|
1699
|
+
});
|
|
1700
|
+
if (this.store.getState().isActive) {
|
|
1701
|
+
this.performAllocation(newIndex);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}, this.config.focusDebounceMs);
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Set focused index immediately (skip debounce)
|
|
1708
|
+
* Use this for programmatic navigation, not scroll
|
|
1709
|
+
*/
|
|
1710
|
+
setFocusedIndexImmediate(index) {
|
|
1711
|
+
const state = this.store.getState();
|
|
1712
|
+
const clampedIndex = Math.max(0, Math.min(index, state.totalItems - 1));
|
|
1713
|
+
if (this.focusDebounceTimer) {
|
|
1714
|
+
clearTimeout(this.focusDebounceTimer);
|
|
1715
|
+
this.focusDebounceTimer = null;
|
|
1716
|
+
}
|
|
1717
|
+
this.pendingFocusedIndex = null;
|
|
1718
|
+
const previousIndex = state.focusedIndex;
|
|
1719
|
+
if (clampedIndex !== previousIndex) {
|
|
1720
|
+
this.store.setState({ focusedIndex: clampedIndex });
|
|
1721
|
+
this.emitEvent({
|
|
1722
|
+
type: "focusChange",
|
|
1723
|
+
index: clampedIndex,
|
|
1724
|
+
previousIndex
|
|
1725
|
+
});
|
|
1726
|
+
if (state.isActive) {
|
|
1727
|
+
this.performAllocation(clampedIndex);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1732
|
+
// PUBLIC API - ALLOCATION
|
|
1733
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1734
|
+
/**
|
|
1735
|
+
* Request allocation for given indices
|
|
1736
|
+
* Returns what needs to be mounted/unmounted
|
|
1737
|
+
*/
|
|
1738
|
+
requestAllocation(indices) {
|
|
1739
|
+
const state = this.store.getState();
|
|
1740
|
+
const validIndices = indices.filter((i) => i >= 0 && i < state.totalItems);
|
|
1741
|
+
const limitedIndices = validIndices.slice(0, this.config.maxAllocations);
|
|
1742
|
+
const changes = computeAllocationChanges(state.activeAllocations, limitedIndices);
|
|
1743
|
+
this.store.setState({ activeAllocations: new Set(limitedIndices) });
|
|
1744
|
+
if (changes.toMount.length > 0 || changes.toUnmount.length > 0) {
|
|
1745
|
+
this.emitEvent({
|
|
1746
|
+
type: "allocationChange",
|
|
1747
|
+
toMount: changes.toMount,
|
|
1748
|
+
toUnmount: changes.toUnmount
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
return { ...changes, success: true };
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Get current active allocations
|
|
1755
|
+
*/
|
|
1756
|
+
getActiveAllocations() {
|
|
1757
|
+
return [...this.store.getState().activeAllocations];
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Check if an index is currently allocated
|
|
1761
|
+
*/
|
|
1762
|
+
isAllocated(index) {
|
|
1763
|
+
return this.store.getState().activeAllocations.has(index);
|
|
1764
|
+
}
|
|
1765
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1766
|
+
// PUBLIC API - NETWORK
|
|
1767
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1768
|
+
/**
|
|
1769
|
+
* Get current network type
|
|
1770
|
+
*/
|
|
1771
|
+
getNetworkType() {
|
|
1772
|
+
return this.store.getState().networkType;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Get prefetch configuration for current network
|
|
1776
|
+
*/
|
|
1777
|
+
getPrefetchConfig() {
|
|
1778
|
+
const networkType = this.store.getState().networkType;
|
|
1779
|
+
return this.config.prefetch[networkType];
|
|
1780
|
+
}
|
|
1781
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1782
|
+
// PUBLIC API - PRELOAD
|
|
1783
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1784
|
+
/**
|
|
1785
|
+
* Set video source getter function
|
|
1786
|
+
* This is called by SDK to provide video data for preloading
|
|
1787
|
+
*
|
|
1788
|
+
* @param getter - Function that returns video info for a given index
|
|
1789
|
+
*/
|
|
1790
|
+
setVideoSourceGetter(getter) {
|
|
1791
|
+
this.videoSourceGetter = getter;
|
|
1792
|
+
this.logger?.debug("[ResourceGovernor] Video source getter set");
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Check if preloading is currently throttled due to scroll thrashing
|
|
1796
|
+
*/
|
|
1797
|
+
isPreloadThrottled() {
|
|
1798
|
+
return this.store.getState().isThrottled;
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Get indices currently being preloaded
|
|
1802
|
+
*/
|
|
1803
|
+
getPreloadingIndices() {
|
|
1804
|
+
return [...this.store.getState().preloadingIndices];
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Manually trigger preload for specific indices
|
|
1808
|
+
* Respects throttling and network conditions
|
|
1809
|
+
*/
|
|
1810
|
+
async triggerPreload(indices) {
|
|
1811
|
+
const state = this.store.getState();
|
|
1812
|
+
if (state.isThrottled || !state.isActive) {
|
|
1813
|
+
this.logger?.debug("[ResourceGovernor] Preload skipped - throttled or inactive");
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
const prefetchConfig = this.getPrefetchConfig();
|
|
1817
|
+
if (!prefetchConfig.prefetchVideo) {
|
|
1818
|
+
this.logger?.debug("[ResourceGovernor] Preload skipped - network does not allow");
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
await this.executePreload(indices);
|
|
1822
|
+
}
|
|
1823
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1824
|
+
// PUBLIC API - EVENTS
|
|
1825
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1826
|
+
/**
|
|
1827
|
+
* Add event listener
|
|
1828
|
+
*/
|
|
1829
|
+
addEventListener(listener) {
|
|
1830
|
+
this.eventListeners.add(listener);
|
|
1831
|
+
return () => this.eventListeners.delete(listener);
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Remove event listener
|
|
1835
|
+
*/
|
|
1836
|
+
removeEventListener(listener) {
|
|
1837
|
+
this.eventListeners.delete(listener);
|
|
1838
|
+
}
|
|
1839
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1840
|
+
// PRIVATE METHODS
|
|
1841
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1842
|
+
/**
|
|
1843
|
+
* Initialize network detection
|
|
1844
|
+
*/
|
|
1845
|
+
async initializeNetwork() {
|
|
1846
|
+
if (!this.networkAdapter) {
|
|
1847
|
+
this.store.setState({ networkType: "wifi" });
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
try {
|
|
1851
|
+
const rawNetworkType = await this.networkAdapter.getNetworkType();
|
|
1852
|
+
const networkType = mapNetworkType(rawNetworkType);
|
|
1853
|
+
this.store.setState({ networkType });
|
|
1854
|
+
this.networkUnsubscribe = this.networkAdapter.onNetworkChange((type) => {
|
|
1855
|
+
const newType = mapNetworkType(type);
|
|
1856
|
+
const previousType = this.store.getState().networkType;
|
|
1857
|
+
if (newType !== previousType) {
|
|
1858
|
+
this.store.setState({ networkType: newType });
|
|
1859
|
+
this.emitEvent({ type: "networkChange", networkType: newType });
|
|
1860
|
+
this.updatePrefetchQueue();
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
} catch {
|
|
1864
|
+
this.logger?.warn("[ResourceGovernor] Failed to detect network, defaulting to wifi");
|
|
1865
|
+
this.store.setState({ networkType: "wifi" });
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Perform allocation for a given focused index
|
|
1870
|
+
*/
|
|
1871
|
+
performAllocation(focusedIndex) {
|
|
1872
|
+
const state = this.store.getState();
|
|
1873
|
+
const windowIndices = calculateWindowIndices(
|
|
1874
|
+
focusedIndex,
|
|
1875
|
+
state.totalItems,
|
|
1876
|
+
this.config.maxAllocations
|
|
1877
|
+
);
|
|
1878
|
+
this.requestAllocation(windowIndices);
|
|
1879
|
+
this.updatePrefetchQueue();
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Update prefetch queue based on current state
|
|
1883
|
+
*/
|
|
1884
|
+
updatePrefetchQueue() {
|
|
1885
|
+
const state = this.store.getState();
|
|
1886
|
+
const prefetchConfig = this.getPrefetchConfig();
|
|
1887
|
+
const posterPrefetchIndices = calculatePrefetchIndices(
|
|
1888
|
+
state.focusedIndex,
|
|
1889
|
+
state.totalItems,
|
|
1890
|
+
prefetchConfig.posterCount,
|
|
1891
|
+
[...state.activeAllocations]
|
|
1892
|
+
);
|
|
1893
|
+
const videoPrefetchIndices = calculatePrefetchIndices(
|
|
1894
|
+
state.focusedIndex,
|
|
1895
|
+
state.totalItems,
|
|
1896
|
+
prefetchConfig.videoSegmentCount,
|
|
1897
|
+
[...state.activeAllocations]
|
|
1898
|
+
);
|
|
1899
|
+
if (posterPrefetchIndices.length > 0) {
|
|
1900
|
+
this.store.setState({ preloadQueue: posterPrefetchIndices });
|
|
1901
|
+
this.emitEvent({ type: "prefetchRequest", indices: posterPrefetchIndices });
|
|
1902
|
+
if (!state.isThrottled && prefetchConfig.prefetchVideo) {
|
|
1903
|
+
this.executePreload(videoPrefetchIndices);
|
|
1904
|
+
}
|
|
1905
|
+
this.executePreloadPosters(posterPrefetchIndices);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1909
|
+
// PRIVATE METHODS - SCROLL THRASHING
|
|
1910
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1911
|
+
/**
|
|
1912
|
+
* Track focus change timestamp for scroll thrashing detection
|
|
1913
|
+
*/
|
|
1914
|
+
trackFocusChange() {
|
|
1915
|
+
const now = Date.now();
|
|
1916
|
+
const { windowMs, maxChangesInWindow, cooldownMs } = this.config.scrollThrashing;
|
|
1917
|
+
this.focusChangeTimestamps.push(now);
|
|
1918
|
+
const windowStart = now - windowMs;
|
|
1919
|
+
this.focusChangeTimestamps = this.focusChangeTimestamps.filter((t) => t >= windowStart);
|
|
1920
|
+
const isCurrentlyThrashing = this.focusChangeTimestamps.length > maxChangesInWindow;
|
|
1921
|
+
const wasThrottled = this.store.getState().isThrottled;
|
|
1922
|
+
if (isCurrentlyThrashing && !wasThrottled) {
|
|
1923
|
+
this.store.setState({ isThrottled: true });
|
|
1924
|
+
this.logger?.debug(
|
|
1925
|
+
`[ResourceGovernor] Scroll thrashing detected (${this.focusChangeTimestamps.length} changes in ${windowMs}ms) - throttling preload`
|
|
1926
|
+
);
|
|
1927
|
+
this.cancelAllPreloads();
|
|
1928
|
+
if (this.thrashingCooldownTimer) {
|
|
1929
|
+
clearTimeout(this.thrashingCooldownTimer);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
if (wasThrottled || isCurrentlyThrashing) {
|
|
1933
|
+
if (this.thrashingCooldownTimer) {
|
|
1934
|
+
clearTimeout(this.thrashingCooldownTimer);
|
|
1935
|
+
}
|
|
1936
|
+
this.thrashingCooldownTimer = setTimeout(() => {
|
|
1937
|
+
this.thrashingCooldownTimer = null;
|
|
1938
|
+
this.store.setState({ isThrottled: false });
|
|
1939
|
+
this.logger?.debug(
|
|
1940
|
+
"[ResourceGovernor] Scroll thrashing cooldown complete - resuming preload"
|
|
1941
|
+
);
|
|
1942
|
+
if (this.store.getState().isActive) {
|
|
1943
|
+
this.updatePrefetchQueue();
|
|
1944
|
+
}
|
|
1945
|
+
}, cooldownMs);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* Cancel all in-progress preloads
|
|
1950
|
+
*/
|
|
1951
|
+
cancelAllPreloads() {
|
|
1952
|
+
if (!this.videoLoader) return;
|
|
1953
|
+
const state = this.store.getState();
|
|
1954
|
+
for (const index of state.preloadingIndices) {
|
|
1955
|
+
const videoInfo = this.videoSourceGetter?.(index);
|
|
1956
|
+
if (videoInfo) {
|
|
1957
|
+
this.videoLoader.cancelPreload(videoInfo.id);
|
|
1958
|
+
this.logger?.debug(`[ResourceGovernor] Cancelled preload for index ${index}`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
this.store.setState({ preloadingIndices: /* @__PURE__ */ new Set() });
|
|
1962
|
+
}
|
|
1963
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1964
|
+
// PRIVATE METHODS - PRELOADING
|
|
1965
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1966
|
+
/**
|
|
1967
|
+
* Execute video preloading for given indices
|
|
1968
|
+
*/
|
|
1969
|
+
async executePreload(indices) {
|
|
1970
|
+
if (!this.videoLoader || !this.videoSourceGetter) {
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const state = this.store.getState();
|
|
1974
|
+
const indicesToPreload = indices.filter((index) => {
|
|
1975
|
+
const videoInfo = this.videoSourceGetter?.(index);
|
|
1976
|
+
if (!videoInfo) return false;
|
|
1977
|
+
if (state.preloadingIndices.has(index)) return false;
|
|
1978
|
+
if (this.videoLoader?.isPreloaded(videoInfo.id)) return false;
|
|
1979
|
+
return true;
|
|
1980
|
+
});
|
|
1981
|
+
if (indicesToPreload.length === 0) return;
|
|
1982
|
+
const newPreloadingIndices = new Set(state.preloadingIndices);
|
|
1983
|
+
for (const index of indicesToPreload) {
|
|
1984
|
+
newPreloadingIndices.add(index);
|
|
1985
|
+
}
|
|
1986
|
+
this.store.setState({ preloadingIndices: newPreloadingIndices });
|
|
1987
|
+
const preloadPromises = indicesToPreload.map(async (index) => {
|
|
1988
|
+
const videoInfo = this.videoSourceGetter?.(index);
|
|
1989
|
+
if (!videoInfo) return;
|
|
1990
|
+
try {
|
|
1991
|
+
this.logger?.debug(
|
|
1992
|
+
`[ResourceGovernor] Preloading video at index ${index} (${videoInfo.id})`
|
|
1993
|
+
);
|
|
1994
|
+
const result = await this.videoLoader?.preload(videoInfo.id, videoInfo.source, {
|
|
1995
|
+
priority: indicesToPreload.indexOf(index)
|
|
1996
|
+
// Lower index = higher priority
|
|
1997
|
+
});
|
|
1998
|
+
if (result?.status === "ready") {
|
|
1999
|
+
this.logger?.debug(
|
|
2000
|
+
`[ResourceGovernor] Preload complete for index ${index} (${result.loadedBytes ?? 0} bytes)`
|
|
2001
|
+
);
|
|
2002
|
+
} else if (result?.status === "error") {
|
|
2003
|
+
this.logger?.warn(
|
|
2004
|
+
`[ResourceGovernor] Preload failed for index ${index}: ${result.error?.message}`
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
} catch (err) {
|
|
2008
|
+
this.logger?.error(
|
|
2009
|
+
"[ResourceGovernor] Preload error",
|
|
2010
|
+
err instanceof Error ? err : new Error(String(err))
|
|
2011
|
+
);
|
|
2012
|
+
} finally {
|
|
2013
|
+
const currentState = this.store.getState();
|
|
2014
|
+
const updatedPreloadingIndices = new Set(currentState.preloadingIndices);
|
|
2015
|
+
updatedPreloadingIndices.delete(index);
|
|
2016
|
+
this.store.setState({ preloadingIndices: updatedPreloadingIndices });
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
await Promise.allSettled(preloadPromises);
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Execute poster preloading for given indices
|
|
2023
|
+
*/
|
|
2024
|
+
async executePreloadPosters(indices) {
|
|
2025
|
+
if (!this.posterLoader || !this.videoSourceGetter) {
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
const preloadPromises = indices.map(async (index) => {
|
|
2029
|
+
const videoInfo = this.videoSourceGetter?.(index);
|
|
2030
|
+
if (!videoInfo?.poster) return;
|
|
2031
|
+
if (this.posterLoader?.isCached(videoInfo.poster)) return;
|
|
2032
|
+
try {
|
|
2033
|
+
await this.posterLoader?.preload(videoInfo.poster);
|
|
2034
|
+
this.logger?.debug(`[ResourceGovernor] Poster preloaded for index ${index}`);
|
|
2035
|
+
} catch {
|
|
2036
|
+
}
|
|
2037
|
+
});
|
|
2038
|
+
await Promise.allSettled(preloadPromises);
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Emit event to all listeners
|
|
2042
|
+
*/
|
|
2043
|
+
emitEvent(event) {
|
|
2044
|
+
for (const listener of this.eventListeners) {
|
|
2045
|
+
try {
|
|
2046
|
+
listener(event);
|
|
2047
|
+
} catch (err) {
|
|
2048
|
+
this.logger?.error(
|
|
2049
|
+
"[ResourceGovernor] Event listener error",
|
|
2050
|
+
err instanceof Error ? err : new Error(String(err))
|
|
2051
|
+
);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
// src/optimistic/types.ts
|
|
2058
|
+
var DEFAULT_OPTIMISTIC_CONFIG = {
|
|
2059
|
+
maxRetries: 3,
|
|
2060
|
+
retryDelayMs: 1e3,
|
|
2061
|
+
autoRetry: true
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
// src/optimistic/OptimisticManager.ts
|
|
2065
|
+
var generateActionId = () => {
|
|
2066
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
2067
|
+
};
|
|
2068
|
+
var createInitialState5 = () => ({
|
|
2069
|
+
pendingActions: /* @__PURE__ */ new Map(),
|
|
2070
|
+
failedQueue: [],
|
|
2071
|
+
hasPending: false,
|
|
2072
|
+
isRetrying: false
|
|
2073
|
+
});
|
|
2074
|
+
var OptimisticManager = class {
|
|
2075
|
+
constructor(config = {}) {
|
|
2076
|
+
/** Event listeners */
|
|
2077
|
+
this.eventListeners = /* @__PURE__ */ new Set();
|
|
2078
|
+
/** Retry timer */
|
|
2079
|
+
this.retryTimer = null;
|
|
2080
|
+
const { interaction, feedManager, logger, ...restConfig } = config;
|
|
2081
|
+
this.config = { ...DEFAULT_OPTIMISTIC_CONFIG, ...restConfig };
|
|
2082
|
+
this.interaction = interaction;
|
|
2083
|
+
this.feedManager = feedManager;
|
|
2084
|
+
this.logger = logger;
|
|
2085
|
+
this.store = createStore(createInitialState5);
|
|
2086
|
+
}
|
|
2087
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2088
|
+
// PUBLIC API - ACTIONS
|
|
2089
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2090
|
+
/**
|
|
2091
|
+
* Like a video with optimistic update
|
|
2092
|
+
*/
|
|
2093
|
+
async like(videoId) {
|
|
2094
|
+
return this.performAction("like", videoId, async () => {
|
|
2095
|
+
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2096
|
+
await this.interaction.like(videoId);
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* Unlike a video with optimistic update
|
|
2101
|
+
*/
|
|
2102
|
+
async unlike(videoId) {
|
|
2103
|
+
return this.performAction("unlike", videoId, async () => {
|
|
2104
|
+
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2105
|
+
await this.interaction.unlike(videoId);
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Toggle like state (like if not liked, unlike if liked)
|
|
2110
|
+
*/
|
|
2111
|
+
async toggleLike(videoId) {
|
|
2112
|
+
const video = this.feedManager?.getVideo(videoId);
|
|
2113
|
+
if (!video) {
|
|
2114
|
+
this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
|
|
2115
|
+
return false;
|
|
2116
|
+
}
|
|
2117
|
+
return video.isLiked ? this.unlike(videoId) : this.like(videoId);
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* Follow a video author with optimistic update
|
|
2121
|
+
*/
|
|
2122
|
+
async follow(videoId) {
|
|
2123
|
+
return this.performAction("follow", videoId, async () => {
|
|
2124
|
+
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2125
|
+
await this.interaction.follow(videoId);
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* Unfollow a video author with optimistic update
|
|
2130
|
+
*/
|
|
2131
|
+
async unfollow(videoId) {
|
|
2132
|
+
return this.performAction("unfollow", videoId, async () => {
|
|
2133
|
+
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2134
|
+
await this.interaction.unfollow(videoId);
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Toggle follow state
|
|
2139
|
+
*/
|
|
2140
|
+
async toggleFollow(videoId) {
|
|
2141
|
+
const video = this.feedManager?.getVideo(videoId);
|
|
2142
|
+
if (!video) {
|
|
2143
|
+
this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
|
|
2144
|
+
return false;
|
|
2145
|
+
}
|
|
2146
|
+
return video.isFollowing ? this.unfollow(videoId) : this.follow(videoId);
|
|
2147
|
+
}
|
|
2148
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2149
|
+
// PUBLIC API - STATE MANAGEMENT
|
|
2150
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2151
|
+
/**
|
|
2152
|
+
* Get all pending actions
|
|
2153
|
+
*/
|
|
2154
|
+
getPendingActions() {
|
|
2155
|
+
return [...this.store.getState().pendingActions.values()];
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Check if there's a pending action for a video
|
|
2159
|
+
*/
|
|
2160
|
+
hasPendingAction(videoId, type) {
|
|
2161
|
+
const actions = this.store.getState().pendingActions;
|
|
2162
|
+
for (const action of actions.values()) {
|
|
2163
|
+
if (action.videoId === videoId && (!type || action.type === type)) {
|
|
2164
|
+
return true;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
return false;
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Get failed actions queue
|
|
2171
|
+
*/
|
|
2172
|
+
getFailedQueue() {
|
|
2173
|
+
const state = this.store.getState();
|
|
2174
|
+
return state.failedQueue.map((id) => state.pendingActions.get(id)).filter((a) => a !== void 0);
|
|
2175
|
+
}
|
|
2176
|
+
/**
|
|
2177
|
+
* Manually retry failed actions
|
|
2178
|
+
*/
|
|
2179
|
+
async retryFailed() {
|
|
2180
|
+
const state = this.store.getState();
|
|
2181
|
+
if (state.isRetrying || state.failedQueue.length === 0) return;
|
|
2182
|
+
this.store.setState({ isRetrying: true });
|
|
2183
|
+
const failedQueue = [...state.failedQueue];
|
|
2184
|
+
for (const actionId of failedQueue) {
|
|
2185
|
+
const action = state.pendingActions.get(actionId);
|
|
2186
|
+
if (action && action.retryCount < this.config.maxRetries) {
|
|
2187
|
+
await this.retryAction(action);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
this.store.setState({ isRetrying: false });
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Clear all failed actions
|
|
2194
|
+
*/
|
|
2195
|
+
clearFailed() {
|
|
2196
|
+
const state = this.store.getState();
|
|
2197
|
+
const newPendingActions = new Map(state.pendingActions);
|
|
2198
|
+
for (const actionId of state.failedQueue) {
|
|
2199
|
+
const action = newPendingActions.get(actionId);
|
|
2200
|
+
if (action) {
|
|
2201
|
+
this.applyRollback(action);
|
|
2202
|
+
newPendingActions.delete(actionId);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
this.store.setState({
|
|
2206
|
+
pendingActions: newPendingActions,
|
|
2207
|
+
failedQueue: [],
|
|
2208
|
+
hasPending: newPendingActions.size > 0
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2212
|
+
// PUBLIC API - LIFECYCLE
|
|
2213
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2214
|
+
/**
|
|
2215
|
+
* Reset manager state
|
|
2216
|
+
*/
|
|
2217
|
+
reset() {
|
|
2218
|
+
this.cancelRetryTimer();
|
|
2219
|
+
this.store.setState(createInitialState5());
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Destroy manager and cleanup
|
|
2223
|
+
*/
|
|
2224
|
+
destroy() {
|
|
2225
|
+
this.cancelRetryTimer();
|
|
2226
|
+
this.eventListeners.clear();
|
|
2227
|
+
this.store.setState(createInitialState5());
|
|
2228
|
+
}
|
|
2229
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2230
|
+
// PUBLIC API - EVENTS
|
|
2231
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2232
|
+
/**
|
|
2233
|
+
* Add event listener
|
|
2234
|
+
*/
|
|
2235
|
+
addEventListener(listener) {
|
|
2236
|
+
this.eventListeners.add(listener);
|
|
2237
|
+
return () => this.eventListeners.delete(listener);
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Remove event listener
|
|
2241
|
+
*/
|
|
2242
|
+
removeEventListener(listener) {
|
|
2243
|
+
this.eventListeners.delete(listener);
|
|
2244
|
+
}
|
|
2245
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2246
|
+
// PRIVATE METHODS
|
|
2247
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2248
|
+
/**
|
|
2249
|
+
* Perform an optimistic action
|
|
2250
|
+
*/
|
|
2251
|
+
async performAction(type, videoId, apiCall) {
|
|
2252
|
+
if (this.hasPendingAction(videoId, type)) {
|
|
2253
|
+
this.logger?.debug(`[OptimisticManager] Duplicate action skipped: ${type} ${videoId}`);
|
|
2254
|
+
return false;
|
|
2255
|
+
}
|
|
2256
|
+
const video = this.feedManager?.getVideo(videoId);
|
|
2257
|
+
if (!video) {
|
|
2258
|
+
this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
|
|
2259
|
+
return false;
|
|
2260
|
+
}
|
|
2261
|
+
const action = {
|
|
2262
|
+
id: generateActionId(),
|
|
2263
|
+
type,
|
|
2264
|
+
videoId,
|
|
2265
|
+
rollbackData: this.createRollbackData(type, video),
|
|
2266
|
+
timestamp: Date.now(),
|
|
2267
|
+
status: "pending",
|
|
2268
|
+
retryCount: 0
|
|
2269
|
+
};
|
|
2270
|
+
this.addPendingAction(action);
|
|
2271
|
+
this.emitEvent({ type: "actionStart", action });
|
|
2272
|
+
this.applyOptimisticUpdate(type, video);
|
|
2273
|
+
this.logger?.debug(`[OptimisticManager] Action started: ${type} ${videoId}`);
|
|
2274
|
+
try {
|
|
2275
|
+
await apiCall();
|
|
2276
|
+
this.markActionSuccess(action.id);
|
|
2277
|
+
this.emitEvent({ type: "actionSuccess", action: { ...action, status: "success" } });
|
|
2278
|
+
this.logger?.debug(`[OptimisticManager] Action succeeded: ${type} ${videoId}`);
|
|
2279
|
+
return true;
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2282
|
+
this.markActionFailed(action.id, err.message);
|
|
2283
|
+
this.applyRollback(action);
|
|
2284
|
+
this.emitEvent({ type: "actionRollback", action: { ...action, status: "failed" } });
|
|
2285
|
+
this.emitEvent({ type: "actionFailed", action: { ...action, status: "failed" }, error: err });
|
|
2286
|
+
this.logger?.warn(`[OptimisticManager] Action failed: ${type} ${videoId}`, {
|
|
2287
|
+
error: err.message
|
|
2288
|
+
});
|
|
2289
|
+
if (this.config.autoRetry) {
|
|
2290
|
+
this.scheduleRetry();
|
|
2291
|
+
}
|
|
2292
|
+
return false;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Create rollback data based on action type
|
|
2297
|
+
*/
|
|
2298
|
+
createRollbackData(type, video) {
|
|
2299
|
+
switch (type) {
|
|
2300
|
+
case "like":
|
|
2301
|
+
return {
|
|
2302
|
+
isLiked: video.isLiked,
|
|
2303
|
+
stats: { ...video.stats }
|
|
2304
|
+
};
|
|
2305
|
+
case "unlike":
|
|
2306
|
+
return {
|
|
2307
|
+
isLiked: video.isLiked,
|
|
2308
|
+
stats: { ...video.stats }
|
|
2309
|
+
};
|
|
2310
|
+
case "follow":
|
|
2311
|
+
case "unfollow":
|
|
2312
|
+
return {
|
|
2313
|
+
isFollowing: video.isFollowing
|
|
2314
|
+
};
|
|
2315
|
+
default:
|
|
2316
|
+
return {};
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Apply optimistic update to feed
|
|
2321
|
+
*/
|
|
2322
|
+
applyOptimisticUpdate(type, video) {
|
|
2323
|
+
if (!this.feedManager) return;
|
|
2324
|
+
switch (type) {
|
|
2325
|
+
case "like":
|
|
2326
|
+
this.feedManager.updateVideo(video.id, {
|
|
2327
|
+
isLiked: true,
|
|
2328
|
+
stats: { ...video.stats, likes: video.stats.likes + 1 }
|
|
2329
|
+
});
|
|
2330
|
+
break;
|
|
2331
|
+
case "unlike":
|
|
2332
|
+
this.feedManager.updateVideo(video.id, {
|
|
2333
|
+
isLiked: false,
|
|
2334
|
+
stats: { ...video.stats, likes: Math.max(0, video.stats.likes - 1) }
|
|
2335
|
+
});
|
|
2336
|
+
break;
|
|
2337
|
+
case "follow":
|
|
2338
|
+
this.feedManager.updateVideo(video.id, {
|
|
2339
|
+
isFollowing: true
|
|
2340
|
+
});
|
|
2341
|
+
break;
|
|
2342
|
+
case "unfollow":
|
|
2343
|
+
this.feedManager.updateVideo(video.id, {
|
|
2344
|
+
isFollowing: false
|
|
2345
|
+
});
|
|
2346
|
+
break;
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Apply rollback
|
|
2351
|
+
*/
|
|
2352
|
+
applyRollback(action) {
|
|
2353
|
+
if (!this.feedManager) return;
|
|
2354
|
+
this.feedManager.updateVideo(action.videoId, action.rollbackData);
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* Add pending action to store
|
|
2358
|
+
*/
|
|
2359
|
+
addPendingAction(action) {
|
|
2360
|
+
const state = this.store.getState();
|
|
2361
|
+
const newPendingActions = new Map(state.pendingActions);
|
|
2362
|
+
newPendingActions.set(action.id, action);
|
|
2363
|
+
this.store.setState({
|
|
2364
|
+
pendingActions: newPendingActions,
|
|
2365
|
+
hasPending: true
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Mark action as success
|
|
2370
|
+
*/
|
|
2371
|
+
markActionSuccess(actionId) {
|
|
2372
|
+
const state = this.store.getState();
|
|
2373
|
+
const newPendingActions = new Map(state.pendingActions);
|
|
2374
|
+
newPendingActions.delete(actionId);
|
|
2375
|
+
const newFailedQueue = state.failedQueue.filter((id) => id !== actionId);
|
|
2376
|
+
this.store.setState({
|
|
2377
|
+
pendingActions: newPendingActions,
|
|
2378
|
+
failedQueue: newFailedQueue,
|
|
2379
|
+
hasPending: newPendingActions.size > 0
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Mark action as failed
|
|
2384
|
+
*/
|
|
2385
|
+
markActionFailed(actionId, error) {
|
|
2386
|
+
const state = this.store.getState();
|
|
2387
|
+
const action = state.pendingActions.get(actionId);
|
|
2388
|
+
if (!action) return;
|
|
2389
|
+
const newPendingActions = new Map(state.pendingActions);
|
|
2390
|
+
newPendingActions.set(actionId, {
|
|
2391
|
+
...action,
|
|
2392
|
+
status: "failed",
|
|
2393
|
+
error
|
|
2394
|
+
});
|
|
2395
|
+
const newFailedQueue = state.failedQueue.includes(actionId) ? state.failedQueue : [...state.failedQueue, actionId];
|
|
2396
|
+
this.store.setState({
|
|
2397
|
+
pendingActions: newPendingActions,
|
|
2398
|
+
failedQueue: newFailedQueue
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Retry a failed action
|
|
2403
|
+
*/
|
|
2404
|
+
async retryAction(action) {
|
|
2405
|
+
this.emitEvent({ type: "retryStart", actionId: action.id });
|
|
2406
|
+
const state = this.store.getState();
|
|
2407
|
+
const newPendingActions = new Map(state.pendingActions);
|
|
2408
|
+
const updatedAction = {
|
|
2409
|
+
...action,
|
|
2410
|
+
retryCount: action.retryCount + 1,
|
|
2411
|
+
status: "pending",
|
|
2412
|
+
error: void 0
|
|
2413
|
+
};
|
|
2414
|
+
newPendingActions.set(action.id, updatedAction);
|
|
2415
|
+
const newFailedQueue = state.failedQueue.filter((id) => id !== action.id);
|
|
2416
|
+
this.store.setState({
|
|
2417
|
+
pendingActions: newPendingActions,
|
|
2418
|
+
failedQueue: newFailedQueue
|
|
2419
|
+
});
|
|
2420
|
+
const video = this.feedManager?.getVideo(action.videoId);
|
|
2421
|
+
if (video) {
|
|
2422
|
+
this.applyOptimisticUpdate(action.type, video);
|
|
2423
|
+
}
|
|
2424
|
+
try {
|
|
2425
|
+
await this.executeApiCall(action.type, action.videoId);
|
|
2426
|
+
this.markActionSuccess(action.id);
|
|
2427
|
+
this.emitEvent({ type: "actionSuccess", action: { ...updatedAction, status: "success" } });
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2430
|
+
if (updatedAction.retryCount >= this.config.maxRetries) {
|
|
2431
|
+
this.emitEvent({ type: "retryExhausted", action: updatedAction });
|
|
2432
|
+
this.markActionFailed(action.id, "Max retries exhausted");
|
|
2433
|
+
} else {
|
|
2434
|
+
this.markActionFailed(action.id, err.message);
|
|
2435
|
+
}
|
|
2436
|
+
this.applyRollback(action);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Execute API call based on action type
|
|
2441
|
+
*/
|
|
2442
|
+
async executeApiCall(type, videoId) {
|
|
2443
|
+
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2444
|
+
switch (type) {
|
|
2445
|
+
case "like":
|
|
2446
|
+
await this.interaction.like(videoId);
|
|
2447
|
+
break;
|
|
2448
|
+
case "unlike":
|
|
2449
|
+
await this.interaction.unlike(videoId);
|
|
2450
|
+
break;
|
|
2451
|
+
case "follow":
|
|
2452
|
+
await this.interaction.follow(videoId);
|
|
2453
|
+
break;
|
|
2454
|
+
case "unfollow":
|
|
2455
|
+
await this.interaction.unfollow(videoId);
|
|
2456
|
+
break;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Schedule retry of failed actions
|
|
2461
|
+
*/
|
|
2462
|
+
scheduleRetry() {
|
|
2463
|
+
if (this.retryTimer) return;
|
|
2464
|
+
this.retryTimer = setTimeout(() => {
|
|
2465
|
+
this.retryTimer = null;
|
|
2466
|
+
this.retryFailed();
|
|
2467
|
+
}, this.config.retryDelayMs);
|
|
2468
|
+
}
|
|
2469
|
+
/**
|
|
2470
|
+
* Cancel retry timer
|
|
2471
|
+
*/
|
|
2472
|
+
cancelRetryTimer() {
|
|
2473
|
+
if (this.retryTimer) {
|
|
2474
|
+
clearTimeout(this.retryTimer);
|
|
2475
|
+
this.retryTimer = null;
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Emit event to all listeners
|
|
2480
|
+
*/
|
|
2481
|
+
emitEvent(event) {
|
|
2482
|
+
for (const listener of this.eventListeners) {
|
|
2483
|
+
try {
|
|
2484
|
+
listener(event);
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
this.logger?.error(
|
|
2487
|
+
"[OptimisticManager] Event listener error",
|
|
2488
|
+
err instanceof Error ? err : new Error(String(err))
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
};
|
|
2494
|
+
|
|
2495
|
+
export { DEFAULT_FEED_CONFIG, DEFAULT_LIFECYCLE_CONFIG, DEFAULT_OPTIMISTIC_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_PREFETCH_CONFIG, DEFAULT_RESOURCE_CONFIG, FeedManager, LifecycleManager, OptimisticManager, PlayerEngine, PlayerStatus, ResourceGovernor, calculatePrefetchIndices, calculateWindowIndices, canPause, canPlay, canSeek, computeAllocationChanges, isActiveState, isValidTransition, mapNetworkType };
|