@xhub-short/core 0.1.0-beta.9 → 1.0.0-beta.19
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 +808 -618
- package/dist/index.js +855 -458
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// ../../node_modules/.pnpm/zustand@5.0.
|
|
1
|
+
// ../../node_modules/.pnpm/zustand@5.0.11_@types+react@19.2.14_react@19.2.4/node_modules/zustand/esm/vanilla.mjs
|
|
2
2
|
var createStoreImpl = (createState) => {
|
|
3
3
|
let state;
|
|
4
4
|
const listeners = /* @__PURE__ */ new Set();
|
|
@@ -35,6 +35,7 @@ var DEFAULT_FEED_CONFIG = {
|
|
|
35
35
|
};
|
|
36
36
|
var DEFAULT_PREFETCH_CACHE_CONFIG = {
|
|
37
37
|
enabled: true,
|
|
38
|
+
maxItems: 10,
|
|
38
39
|
maxVideos: 10,
|
|
39
40
|
storageKey: "sv-prefetch-cache",
|
|
40
41
|
enableDynamicEviction: true,
|
|
@@ -54,8 +55,9 @@ var createInitialState = () => ({
|
|
|
54
55
|
lastFetchTime: null
|
|
55
56
|
});
|
|
56
57
|
var _FeedManager = class _FeedManager {
|
|
57
|
-
constructor(dataSource, config = {}, storage, prefetchConfig) {
|
|
58
|
+
constructor(dataSource, config = {}, storage, prefetchConfig, logger) {
|
|
58
59
|
this.dataSource = dataSource;
|
|
60
|
+
this.logger = logger;
|
|
59
61
|
/** Abort controller for cancelling in-flight requests */
|
|
60
62
|
this.abortController = null;
|
|
61
63
|
/**
|
|
@@ -68,6 +70,17 @@ var _FeedManager = class _FeedManager {
|
|
|
68
70
|
* Used for garbage collection
|
|
69
71
|
*/
|
|
70
72
|
this.accessOrder = /* @__PURE__ */ new Map();
|
|
73
|
+
/**
|
|
74
|
+
* Track items that have already triggered predictive preload
|
|
75
|
+
* to avoid duplicate requests.
|
|
76
|
+
*/
|
|
77
|
+
this.preloadedItemIds = /* @__PURE__ */ new Set();
|
|
78
|
+
/**
|
|
79
|
+
* Recommend feed snapshot — preserved when switching to playlist mode.
|
|
80
|
+
* Stored here (not in React refs) because FeedManager is a singleton
|
|
81
|
+
* that survives component remounts caused by conditional rendering.
|
|
82
|
+
*/
|
|
83
|
+
this.recommendSnapshot = null;
|
|
71
84
|
this.config = { ...DEFAULT_FEED_CONFIG, ...config };
|
|
72
85
|
this.prefetchConfig = { ...DEFAULT_PREFETCH_CACHE_CONFIG, ...prefetchConfig };
|
|
73
86
|
this.storage = storage ?? null;
|
|
@@ -112,6 +125,25 @@ var _FeedManager = class _FeedManager {
|
|
|
112
125
|
// ═══════════════════════════════════════════════════════════════
|
|
113
126
|
// PUBLIC API
|
|
114
127
|
// ═══════════════════════════════════════════════════════════════
|
|
128
|
+
/**
|
|
129
|
+
* Get current data source
|
|
130
|
+
*/
|
|
131
|
+
getDataSource() {
|
|
132
|
+
return this.dataSource;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Update data source dynamically
|
|
136
|
+
* Used for switching between Recommendation and Playlist modes
|
|
137
|
+
*
|
|
138
|
+
* @param dataSource - New data source adapter
|
|
139
|
+
* @param options - Options for state transition
|
|
140
|
+
*/
|
|
141
|
+
setDataSource(dataSource, options = { reset: true }) {
|
|
142
|
+
this.dataSource = dataSource;
|
|
143
|
+
if (options.reset) {
|
|
144
|
+
this.reset();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
115
147
|
/**
|
|
116
148
|
* Load initial feed data
|
|
117
149
|
*
|
|
@@ -124,14 +156,14 @@ var _FeedManager = class _FeedManager {
|
|
|
124
156
|
* - If a request for the same cursor is already in-flight, returns the existing Promise
|
|
125
157
|
* - Prevents duplicate API calls from rapid UI interactions
|
|
126
158
|
*/
|
|
127
|
-
async loadInitial() {
|
|
128
|
-
if (_FeedManager.globalMemoryCache) {
|
|
159
|
+
async loadInitial(options) {
|
|
160
|
+
if (!options?.replace && _FeedManager.globalMemoryCache) {
|
|
129
161
|
const { items, nextCursor } = _FeedManager.globalMemoryCache;
|
|
130
162
|
this.hydrateFromSnapshot(items, nextCursor, { markAsStale: false });
|
|
131
163
|
_FeedManager.globalMemoryCache = null;
|
|
132
164
|
return;
|
|
133
165
|
}
|
|
134
|
-
const dedupeKey = "__initial__";
|
|
166
|
+
const dedupeKey = options?.replace ? "__initial_replace__" : "__initial__";
|
|
135
167
|
const existingRequest = this.inFlightRequests.get(dedupeKey);
|
|
136
168
|
if (existingRequest) {
|
|
137
169
|
return existingRequest;
|
|
@@ -141,7 +173,7 @@ var _FeedManager = class _FeedManager {
|
|
|
141
173
|
return;
|
|
142
174
|
}
|
|
143
175
|
this.store.setState({ loading: true, error: null });
|
|
144
|
-
const request = this.executeLoadInitial();
|
|
176
|
+
const request = this.executeLoadInitial(options?.replace);
|
|
145
177
|
this.inFlightRequests.set(dedupeKey, request);
|
|
146
178
|
try {
|
|
147
179
|
await request;
|
|
@@ -152,9 +184,9 @@ var _FeedManager = class _FeedManager {
|
|
|
152
184
|
/**
|
|
153
185
|
* Internal: Execute load initial logic
|
|
154
186
|
*/
|
|
155
|
-
async executeLoadInitial() {
|
|
187
|
+
async executeLoadInitial(replace = false) {
|
|
156
188
|
try {
|
|
157
|
-
await this.fetchWithRetry();
|
|
189
|
+
await this.fetchWithRetry(void 0, replace);
|
|
158
190
|
this.updatePrefetchCache();
|
|
159
191
|
} catch (error) {
|
|
160
192
|
this.handleError(error, "loadInitial");
|
|
@@ -215,7 +247,7 @@ var _FeedManager = class _FeedManager {
|
|
|
215
247
|
}
|
|
216
248
|
try {
|
|
217
249
|
const response = await this.dataSource.fetchFeed();
|
|
218
|
-
this.
|
|
250
|
+
this.mergeItems(response.items, true);
|
|
219
251
|
this.store.setState({
|
|
220
252
|
cursor: response.nextCursor,
|
|
221
253
|
hasMore: response.hasMore,
|
|
@@ -226,27 +258,50 @@ var _FeedManager = class _FeedManager {
|
|
|
226
258
|
}
|
|
227
259
|
}
|
|
228
260
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
261
|
+
* Handle playback progress and trigger predictive preloading
|
|
262
|
+
*
|
|
263
|
+
* @param itemId - ID of the currently playing item
|
|
264
|
+
* @param progress - Current playback progress (0-1)
|
|
265
|
+
* @param governor - Resource governor to trigger preload
|
|
266
|
+
* @param threshold - Progress threshold to trigger preload (default: 0.2)
|
|
267
|
+
*/
|
|
268
|
+
handlePlaybackProgress(itemId, progress, governor, threshold = 0.2) {
|
|
269
|
+
if (this.preloadedItemIds.has(itemId)) return;
|
|
270
|
+
if (progress >= threshold) {
|
|
271
|
+
this.preloadedItemIds.add(itemId);
|
|
272
|
+
const state = this.store.getState();
|
|
273
|
+
const currentIndex = state.displayOrder.indexOf(itemId);
|
|
274
|
+
if (currentIndex !== -1) {
|
|
275
|
+
const nextIndices = [currentIndex + 1, currentIndex + 2].filter(
|
|
276
|
+
(idx) => idx < state.displayOrder.length
|
|
277
|
+
);
|
|
278
|
+
if (nextIndices.length > 0) {
|
|
279
|
+
governor.triggerPreload(nextIndices);
|
|
280
|
+
this.logger?.debug(
|
|
281
|
+
`[FeedManager] Predictive preload triggered for indices: ${nextIndices.join(", ")}`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
getItem(id) {
|
|
288
|
+
const item = this.store.getState().itemsById.get(id);
|
|
289
|
+
if (item) {
|
|
235
290
|
this.accessOrder.set(id, Date.now());
|
|
236
291
|
}
|
|
237
|
-
return
|
|
292
|
+
return item;
|
|
238
293
|
}
|
|
239
294
|
/**
|
|
240
|
-
* Get ordered list of
|
|
295
|
+
* Get ordered list of items
|
|
241
296
|
*/
|
|
242
|
-
|
|
297
|
+
getItems() {
|
|
243
298
|
const state = this.store.getState();
|
|
244
299
|
return state.displayOrder.map((id) => state.itemsById.get(id)).filter((v) => v !== void 0);
|
|
245
300
|
}
|
|
246
301
|
/**
|
|
247
|
-
* Update
|
|
302
|
+
* Update an item in the feed (for optimistic updates)
|
|
248
303
|
*/
|
|
249
|
-
|
|
304
|
+
updateItem(id, updates) {
|
|
250
305
|
const state = this.store.getState();
|
|
251
306
|
const existing = state.itemsById.get(id);
|
|
252
307
|
if (existing) {
|
|
@@ -255,6 +310,77 @@ var _FeedManager = class _FeedManager {
|
|
|
255
310
|
this.store.setState({ itemsById: newItemsById });
|
|
256
311
|
}
|
|
257
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Replace all items in the feed (e.g. for Playlist synchronization)
|
|
315
|
+
*/
|
|
316
|
+
replaceItems(items) {
|
|
317
|
+
const newItemsById = /* @__PURE__ */ new Map();
|
|
318
|
+
const newDisplayOrder = [];
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
for (const item of items) {
|
|
321
|
+
newItemsById.set(item.id, item);
|
|
322
|
+
newDisplayOrder.push(item.id);
|
|
323
|
+
this.accessOrder.set(item.id, now);
|
|
324
|
+
}
|
|
325
|
+
this.store.setState({
|
|
326
|
+
itemsById: newItemsById,
|
|
327
|
+
displayOrder: newDisplayOrder,
|
|
328
|
+
cursor: null,
|
|
329
|
+
hasMore: false
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* @deprecated Use getItem instead
|
|
334
|
+
*/
|
|
335
|
+
getVideo(id) {
|
|
336
|
+
return this.getItem(id);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* @deprecated Use getItems instead
|
|
340
|
+
*/
|
|
341
|
+
getVideos() {
|
|
342
|
+
return this.getItems();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* @deprecated Use updateItem instead
|
|
346
|
+
*/
|
|
347
|
+
updateVideo(id, updates) {
|
|
348
|
+
this.updateItem(id, updates);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Remove an item from the feed
|
|
352
|
+
*
|
|
353
|
+
* Used for:
|
|
354
|
+
* - Report: Remove reported content from feed
|
|
355
|
+
* - Not Interested: Remove content user doesn't want to see
|
|
356
|
+
*
|
|
357
|
+
* @param id - Content ID to remove
|
|
358
|
+
* @returns true if item was removed, false if not found
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* ```typescript
|
|
362
|
+
* // User reports a content item
|
|
363
|
+
* const wasRemoved = feedManager.removeItem(itemId);
|
|
364
|
+
* if (wasRemoved) {
|
|
365
|
+
* // Navigate to next item
|
|
366
|
+
* }
|
|
367
|
+
* ```
|
|
368
|
+
*/
|
|
369
|
+
removeItem(id) {
|
|
370
|
+
const state = this.store.getState();
|
|
371
|
+
if (!state.itemsById.has(id)) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
const newItemsById = new Map(state.itemsById);
|
|
375
|
+
newItemsById.delete(id);
|
|
376
|
+
const newDisplayOrder = state.displayOrder.filter((itemId) => itemId !== id);
|
|
377
|
+
this.accessOrder.delete(id);
|
|
378
|
+
this.store.setState({
|
|
379
|
+
itemsById: newItemsById,
|
|
380
|
+
displayOrder: newDisplayOrder
|
|
381
|
+
});
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
258
384
|
/**
|
|
259
385
|
* Check if data is stale and needs revalidation
|
|
260
386
|
*/
|
|
@@ -270,6 +396,7 @@ var _FeedManager = class _FeedManager {
|
|
|
270
396
|
this.cancelPendingRequests();
|
|
271
397
|
this.inFlightRequests.clear();
|
|
272
398
|
this.accessOrder.clear();
|
|
399
|
+
this.preloadedItemIds.clear();
|
|
273
400
|
this.store.setState(createInitialState());
|
|
274
401
|
}
|
|
275
402
|
/**
|
|
@@ -296,7 +423,7 @@ var _FeedManager = class _FeedManager {
|
|
|
296
423
|
* Used by LifecycleManager to restore state without API call.
|
|
297
424
|
* This bypasses normal data flow for state restoration.
|
|
298
425
|
*
|
|
299
|
-
* @param items -
|
|
426
|
+
* @param items - Content items from snapshot
|
|
300
427
|
* @param cursor - Pagination cursor from snapshot
|
|
301
428
|
* @param options - Additional hydration options
|
|
302
429
|
*/
|
|
@@ -307,10 +434,10 @@ var _FeedManager = class _FeedManager {
|
|
|
307
434
|
const now = Date.now();
|
|
308
435
|
const newItemsById = /* @__PURE__ */ new Map();
|
|
309
436
|
const newDisplayOrder = [];
|
|
310
|
-
for (const
|
|
311
|
-
newItemsById.set(
|
|
312
|
-
newDisplayOrder.push(
|
|
313
|
-
this.accessOrder.set(
|
|
437
|
+
for (const item of items) {
|
|
438
|
+
newItemsById.set(item.id, item);
|
|
439
|
+
newDisplayOrder.push(item.id);
|
|
440
|
+
this.accessOrder.set(item.id, now);
|
|
314
441
|
}
|
|
315
442
|
this.store.setState({
|
|
316
443
|
itemsById: newItemsById,
|
|
@@ -325,26 +452,67 @@ var _FeedManager = class _FeedManager {
|
|
|
325
452
|
});
|
|
326
453
|
}
|
|
327
454
|
// ═══════════════════════════════════════════════════════════════
|
|
455
|
+
// RECOMMEND SNAPSHOT (for playlist ↔ recommend switching)
|
|
456
|
+
// ═══════════════════════════════════════════════════════════════
|
|
457
|
+
/**
|
|
458
|
+
* Save the current recommend feed state before switching to playlist mode.
|
|
459
|
+
*
|
|
460
|
+
* @param activeIndex - Current scroll position (active item index)
|
|
461
|
+
*/
|
|
462
|
+
saveRecommendSnapshot(activeIndex) {
|
|
463
|
+
this.recommendSnapshot = {
|
|
464
|
+
items: this.getItems(),
|
|
465
|
+
cursor: this.store.getState().cursor,
|
|
466
|
+
hasMore: this.store.getState().hasMore,
|
|
467
|
+
activeIndex
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Restore the recommend feed state after exiting playlist mode.
|
|
472
|
+
* Delegates to hydrateFromSnapshot() internally to avoid duplicating hydration logic.
|
|
473
|
+
*
|
|
474
|
+
* @returns The saved activeIndex, or null if no snapshot was available
|
|
475
|
+
*/
|
|
476
|
+
restoreRecommendSnapshot() {
|
|
477
|
+
const snapshot = this.recommendSnapshot;
|
|
478
|
+
if (!snapshot || snapshot.items.length === 0) {
|
|
479
|
+
this.recommendSnapshot = null;
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
this.hydrateFromSnapshot(snapshot.items, snapshot.cursor, {
|
|
483
|
+
markAsStale: false
|
|
484
|
+
});
|
|
485
|
+
const savedIndex = snapshot.activeIndex;
|
|
486
|
+
this.recommendSnapshot = null;
|
|
487
|
+
return savedIndex;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Check if a recommend snapshot exists (for conditional logic in hooks)
|
|
491
|
+
*/
|
|
492
|
+
hasRecommendSnapshot() {
|
|
493
|
+
return this.recommendSnapshot !== null && this.recommendSnapshot.items.length > 0;
|
|
494
|
+
}
|
|
495
|
+
// ═══════════════════════════════════════════════════════════════
|
|
328
496
|
// PREFETCH CACHE METHODS
|
|
329
497
|
// ═══════════════════════════════════════════════════════════════
|
|
330
498
|
/**
|
|
331
499
|
* Update prefetch cache with current feed tail
|
|
332
500
|
* Called automatically after loadInitial() and loadMore()
|
|
333
501
|
*
|
|
334
|
-
* Strategy: Cache the LAST N
|
|
335
|
-
* These are
|
|
502
|
+
* Strategy: Cache the LAST N items (tail of feed)
|
|
503
|
+
* These are items user hasn't seen yet, perfect for instant display
|
|
336
504
|
*/
|
|
337
505
|
updatePrefetchCache() {
|
|
338
506
|
if (!this.prefetchConfig.enabled) return;
|
|
339
507
|
if (!this.storage) return;
|
|
340
508
|
const state = this.store.getState();
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
509
|
+
const allItems = this.getItems();
|
|
510
|
+
if (allItems.length < this.prefetchConfig.maxItems) {
|
|
343
511
|
return;
|
|
344
512
|
}
|
|
345
|
-
const
|
|
513
|
+
const tailItems = allItems.slice(-this.prefetchConfig.maxItems);
|
|
346
514
|
const cacheData = {
|
|
347
|
-
items:
|
|
515
|
+
items: tailItems,
|
|
348
516
|
savedAt: Date.now(),
|
|
349
517
|
cursor: state.cursor
|
|
350
518
|
};
|
|
@@ -387,6 +555,35 @@ var _FeedManager = class _FeedManager {
|
|
|
387
555
|
// Trigger revalidation
|
|
388
556
|
});
|
|
389
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* Load prefetch cache synchronously (zero-flash optimization)
|
|
560
|
+
*
|
|
561
|
+
* Uses `storage.getSync()` for synchronous localStorage access.
|
|
562
|
+
* This allows hydrating the feed during React's synchronous render phase,
|
|
563
|
+
* preventing any flash of loading/empty state when cached data exists.
|
|
564
|
+
*
|
|
565
|
+
* Returns null if:
|
|
566
|
+
* - Prefetch cache is disabled
|
|
567
|
+
* - No storage adapter
|
|
568
|
+
* - Storage doesn't support sync reads (e.g., IndexedDB)
|
|
569
|
+
* - No cached data
|
|
570
|
+
*
|
|
571
|
+
* @returns Cached feed data or null
|
|
572
|
+
*/
|
|
573
|
+
loadPrefetchCacheSync() {
|
|
574
|
+
if (!this.prefetchConfig.enabled) return null;
|
|
575
|
+
if (!this.storage) return null;
|
|
576
|
+
if (!this.storage.getSync) return null;
|
|
577
|
+
try {
|
|
578
|
+
const data = this.storage.getSync(this.prefetchConfig.storageKey);
|
|
579
|
+
if (!data || !data.items || data.items.length === 0) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
return data;
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
390
587
|
/**
|
|
391
588
|
* Clear prefetch cache
|
|
392
589
|
* Call when user logs out or data should be invalidated
|
|
@@ -399,26 +596,26 @@ var _FeedManager = class _FeedManager {
|
|
|
399
596
|
}
|
|
400
597
|
}
|
|
401
598
|
/**
|
|
402
|
-
* Evict
|
|
599
|
+
* Evict items that user has scrolled past from prefetch cache
|
|
403
600
|
* Called when user's focusedIndex changes
|
|
404
601
|
*
|
|
405
|
-
* Strategy: Remove all
|
|
406
|
-
* This ensures user doesn't rewatch
|
|
602
|
+
* Strategy: Remove all items at or before current position
|
|
603
|
+
* This ensures user doesn't rewatch items on reload
|
|
407
604
|
*
|
|
408
|
-
* @param currentIndex - Current focused
|
|
605
|
+
* @param currentIndex - Current focused item index in feed
|
|
409
606
|
*/
|
|
410
|
-
async
|
|
607
|
+
async evictViewedItemsFromCache(currentIndex) {
|
|
411
608
|
if (!this.prefetchConfig.enabled) return;
|
|
412
609
|
if (!this.prefetchConfig.enableDynamicEviction) return;
|
|
413
610
|
if (!this.storage) return;
|
|
414
611
|
try {
|
|
415
612
|
const cache = await this.loadPrefetchCache();
|
|
416
613
|
if (!cache || cache.items.length === 0) return;
|
|
417
|
-
const
|
|
418
|
-
const
|
|
419
|
-
if (!
|
|
420
|
-
const
|
|
421
|
-
const updatedItems = cache.items.filter((item) => !
|
|
614
|
+
const allItems = this.getItems();
|
|
615
|
+
const currentItem = allItems[currentIndex];
|
|
616
|
+
if (!currentItem) return;
|
|
617
|
+
const viewedItemIds = new Set(allItems.slice(0, currentIndex + 1).map((v) => v.id));
|
|
618
|
+
const updatedItems = cache.items.filter((item) => !viewedItemIds.has(item.id));
|
|
422
619
|
if (updatedItems.length === cache.items.length) return;
|
|
423
620
|
if (updatedItems.length === 0) {
|
|
424
621
|
await this.storage.remove(this.prefetchConfig.storageKey);
|
|
@@ -445,13 +642,17 @@ var _FeedManager = class _FeedManager {
|
|
|
445
642
|
/**
|
|
446
643
|
* Fetch with exponential backoff retry
|
|
447
644
|
*/
|
|
448
|
-
async fetchWithRetry(cursor) {
|
|
645
|
+
async fetchWithRetry(cursor, replace = false) {
|
|
449
646
|
let lastError = null;
|
|
450
647
|
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
|
451
648
|
try {
|
|
452
649
|
this.abortController = new AbortController();
|
|
453
650
|
const response = await this.dataSource.fetchFeed(cursor);
|
|
454
|
-
|
|
651
|
+
if (replace) {
|
|
652
|
+
this.replaceItems(response.items);
|
|
653
|
+
} else {
|
|
654
|
+
this.addItems(response.items);
|
|
655
|
+
}
|
|
455
656
|
this.store.setState({
|
|
456
657
|
cursor: response.nextCursor,
|
|
457
658
|
hasMore: response.hasMore,
|
|
@@ -474,20 +675,23 @@ var _FeedManager = class _FeedManager {
|
|
|
474
675
|
throw lastError;
|
|
475
676
|
}
|
|
476
677
|
/**
|
|
477
|
-
* Add
|
|
678
|
+
* Add items with deduplication
|
|
478
679
|
* Triggers garbage collection if cache exceeds maxCacheSize
|
|
479
680
|
*/
|
|
480
|
-
|
|
681
|
+
addItems(items) {
|
|
481
682
|
const state = this.store.getState();
|
|
482
683
|
const newItemsById = new Map(state.itemsById);
|
|
483
684
|
const newDisplayOrder = [...state.displayOrder];
|
|
484
685
|
const now = Date.now();
|
|
485
|
-
for (const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
686
|
+
for (const item of items) {
|
|
687
|
+
const existing = newItemsById.get(item.id);
|
|
688
|
+
if (existing) {
|
|
689
|
+
newItemsById.set(item.id, { ...existing, ...item });
|
|
690
|
+
} else {
|
|
691
|
+
newItemsById.set(item.id, item);
|
|
692
|
+
newDisplayOrder.push(item.id);
|
|
490
693
|
}
|
|
694
|
+
this.accessOrder.set(item.id, now);
|
|
491
695
|
}
|
|
492
696
|
this.store.setState({
|
|
493
697
|
itemsById: newItemsById,
|
|
@@ -498,19 +702,19 @@ var _FeedManager = class _FeedManager {
|
|
|
498
702
|
}
|
|
499
703
|
}
|
|
500
704
|
/**
|
|
501
|
-
* Merge
|
|
502
|
-
* Updates existing
|
|
705
|
+
* Merge items (for SWR revalidation)
|
|
706
|
+
* Updates existing items, adds new ones at the beginning
|
|
503
707
|
*/
|
|
504
|
-
|
|
708
|
+
mergeItems(items, prepend) {
|
|
505
709
|
const state = this.store.getState();
|
|
506
710
|
const newItemsById = new Map(state.itemsById);
|
|
507
711
|
const newIds = [];
|
|
508
|
-
for (const
|
|
509
|
-
if (newItemsById.has(
|
|
510
|
-
newItemsById.set(
|
|
712
|
+
for (const item of items) {
|
|
713
|
+
if (newItemsById.has(item.id)) {
|
|
714
|
+
newItemsById.set(item.id, item);
|
|
511
715
|
} else {
|
|
512
|
-
newItemsById.set(
|
|
513
|
-
newIds.push(
|
|
716
|
+
newItemsById.set(item.id, item);
|
|
717
|
+
newIds.push(item.id);
|
|
514
718
|
}
|
|
515
719
|
}
|
|
516
720
|
const newDisplayOrder = prepend ? [...newIds, ...state.displayOrder] : [...state.displayOrder, ...newIds];
|
|
@@ -583,9 +787,9 @@ var _FeedManager = class _FeedManager {
|
|
|
583
787
|
* Run garbage collection using LRU (Least Recently Used) policy
|
|
584
788
|
*
|
|
585
789
|
* When cache size exceeds maxCacheSize:
|
|
586
|
-
* 1. Sort
|
|
587
|
-
* 2. Evict oldest
|
|
588
|
-
* 3. Keep
|
|
790
|
+
* 1. Sort items by last access time (oldest first)
|
|
791
|
+
* 2. Evict oldest items until cache is within limit
|
|
792
|
+
* 3. Keep items that are currently in viewport (most recent in displayOrder)
|
|
589
793
|
*
|
|
590
794
|
* @returns Number of evicted items
|
|
591
795
|
*/
|
|
@@ -811,7 +1015,9 @@ var PlayerEngine = class {
|
|
|
811
1015
|
loopCount: 0,
|
|
812
1016
|
watchTime: 0,
|
|
813
1017
|
error: null,
|
|
814
|
-
ended: false
|
|
1018
|
+
ended: false,
|
|
1019
|
+
playbackRate: this.store.getState().playbackRate
|
|
1020
|
+
// Persist speed across videos (AC 5)
|
|
815
1021
|
});
|
|
816
1022
|
this.emitEvent({ type: "videoChange", video });
|
|
817
1023
|
this.logger?.debug(`[PlayerEngine] Loaded video: ${video.id}`);
|
|
@@ -1653,25 +1859,7 @@ var LifecycleManager = class {
|
|
|
1653
1859
|
this.store.setState({ isSaving: true });
|
|
1654
1860
|
this.emitEvent({ type: "saveStart" });
|
|
1655
1861
|
try {
|
|
1656
|
-
const snapshot =
|
|
1657
|
-
items: data.items,
|
|
1658
|
-
cursor: data.cursor,
|
|
1659
|
-
focusedIndex: data.focusedIndex,
|
|
1660
|
-
scrollPosition: data.scrollPosition,
|
|
1661
|
-
savedAt: Date.now(),
|
|
1662
|
-
version: this.config.version
|
|
1663
|
-
};
|
|
1664
|
-
if (this.config.restorePlaybackPosition) {
|
|
1665
|
-
if (data.playbackTime !== void 0) {
|
|
1666
|
-
snapshot.playbackTime = data.playbackTime;
|
|
1667
|
-
}
|
|
1668
|
-
if (data.currentVideoId !== void 0) {
|
|
1669
|
-
snapshot.currentVideoId = data.currentVideoId;
|
|
1670
|
-
}
|
|
1671
|
-
if (data.restoreFrame !== void 0) {
|
|
1672
|
-
snapshot.restoreFrame = data.restoreFrame;
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1862
|
+
const snapshot = this.createSnapshot(data);
|
|
1675
1863
|
await this.storage.saveSnapshot(snapshot);
|
|
1676
1864
|
const timestamp = Date.now();
|
|
1677
1865
|
this.store.setState({
|
|
@@ -1812,6 +2000,25 @@ var LifecycleManager = class {
|
|
|
1812
2000
|
isSnapshotStale(snapshot) {
|
|
1813
2001
|
return Date.now() - snapshot.savedAt > this.config.revalidationThresholdMs;
|
|
1814
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Create session snapshot from data
|
|
2005
|
+
*/
|
|
2006
|
+
createSnapshot(data) {
|
|
2007
|
+
const snapshot = {
|
|
2008
|
+
items: data.items,
|
|
2009
|
+
cursor: data.cursor,
|
|
2010
|
+
focusedIndex: data.focusedIndex,
|
|
2011
|
+
scrollPosition: data.scrollPosition,
|
|
2012
|
+
savedAt: Date.now(),
|
|
2013
|
+
version: this.config.version
|
|
2014
|
+
};
|
|
2015
|
+
if (this.config.restorePlaybackPosition) {
|
|
2016
|
+
if (data.playbackTime !== void 0) snapshot.playbackTime = data.playbackTime;
|
|
2017
|
+
if (data.currentVideoId !== void 0) snapshot.currentVideoId = data.currentVideoId;
|
|
2018
|
+
if (data.restoreFrame !== void 0) snapshot.restoreFrame = data.restoreFrame;
|
|
2019
|
+
}
|
|
2020
|
+
return snapshot;
|
|
2021
|
+
}
|
|
1815
2022
|
/**
|
|
1816
2023
|
* Emit event to all listeners
|
|
1817
2024
|
*/
|
|
@@ -2360,39 +2567,47 @@ var ResourceGovernor = class {
|
|
|
2360
2567
|
newPreloadingIndices.add(index);
|
|
2361
2568
|
}
|
|
2362
2569
|
this.store.setState({ preloadingIndices: newPreloadingIndices });
|
|
2363
|
-
const
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2570
|
+
const maxParallel = 2;
|
|
2571
|
+
const executeInBatches = async () => {
|
|
2572
|
+
for (let i = 0; i < indicesToPreload.length; i += maxParallel) {
|
|
2573
|
+
const batch = indicesToPreload.slice(i, i + maxParallel);
|
|
2574
|
+
await Promise.allSettled(batch.map((index) => this.preloadOne(index, indicesToPreload)));
|
|
2575
|
+
}
|
|
2576
|
+
};
|
|
2577
|
+
await executeInBatches();
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* Internal helper to preload a single video
|
|
2581
|
+
*/
|
|
2582
|
+
async preloadOne(index, indicesToPreload) {
|
|
2583
|
+
const videoInfo = this.videoSourceGetter?.(index);
|
|
2584
|
+
if (!videoInfo) return;
|
|
2585
|
+
try {
|
|
2586
|
+
this.logger?.debug(`[ResourceGovernor] Preloading video at index ${index} (${videoInfo.id})`);
|
|
2587
|
+
const result = await this.videoLoader?.preload(videoInfo.id, videoInfo.source, {
|
|
2588
|
+
priority: indicesToPreload.indexOf(index)
|
|
2589
|
+
// Lower index = higher priority
|
|
2590
|
+
});
|
|
2591
|
+
if (result?.status === "ready") {
|
|
2367
2592
|
this.logger?.debug(
|
|
2368
|
-
`[ResourceGovernor]
|
|
2593
|
+
`[ResourceGovernor] Preload complete for index ${index} (${result.loadedBytes ?? 0} bytes)`
|
|
2369
2594
|
);
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
});
|
|
2374
|
-
if (result?.status === "ready") {
|
|
2375
|
-
this.logger?.debug(
|
|
2376
|
-
`[ResourceGovernor] Preload complete for index ${index} (${result.loadedBytes ?? 0} bytes)`
|
|
2377
|
-
);
|
|
2378
|
-
} else if (result?.status === "error") {
|
|
2379
|
-
this.logger?.warn(
|
|
2380
|
-
`[ResourceGovernor] Preload failed for index ${index}: ${result.error?.message}`
|
|
2381
|
-
);
|
|
2382
|
-
}
|
|
2383
|
-
} catch (err) {
|
|
2384
|
-
this.logger?.error(
|
|
2385
|
-
"[ResourceGovernor] Preload error",
|
|
2386
|
-
err instanceof Error ? err : new Error(String(err))
|
|
2595
|
+
} else if (result?.status === "error") {
|
|
2596
|
+
this.logger?.warn(
|
|
2597
|
+
`[ResourceGovernor] Preload failed for index ${index}: ${result.error?.message}`
|
|
2387
2598
|
);
|
|
2388
|
-
} finally {
|
|
2389
|
-
const currentState = this.store.getState();
|
|
2390
|
-
const updatedPreloadingIndices = new Set(currentState.preloadingIndices);
|
|
2391
|
-
updatedPreloadingIndices.delete(index);
|
|
2392
|
-
this.store.setState({ preloadingIndices: updatedPreloadingIndices });
|
|
2393
2599
|
}
|
|
2394
|
-
})
|
|
2395
|
-
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
this.logger?.error(
|
|
2602
|
+
"[ResourceGovernor] Preload error",
|
|
2603
|
+
err instanceof Error ? err : new Error(String(err))
|
|
2604
|
+
);
|
|
2605
|
+
} finally {
|
|
2606
|
+
const currentState = this.store.getState();
|
|
2607
|
+
const updatedPreloadingIndices = new Set(currentState.preloadingIndices);
|
|
2608
|
+
updatedPreloadingIndices.delete(index);
|
|
2609
|
+
this.store.setState({ preloadingIndices: updatedPreloadingIndices });
|
|
2610
|
+
}
|
|
2396
2611
|
}
|
|
2397
2612
|
/**
|
|
2398
2613
|
* Execute poster preloading for given indices
|
|
@@ -2453,8 +2668,10 @@ var OptimisticManager = class {
|
|
|
2453
2668
|
this.eventListeners = /* @__PURE__ */ new Set();
|
|
2454
2669
|
/** Retry timer */
|
|
2455
2670
|
this.retryTimer = null;
|
|
2456
|
-
/** Debounce timers for like/unlike per
|
|
2671
|
+
/** Debounce timers for like/unlike per item */
|
|
2457
2672
|
this.likeDebounceTimers = /* @__PURE__ */ new Map();
|
|
2673
|
+
/** Intended like state while debouncing (itemId -> isLiked) */
|
|
2674
|
+
this.intendedLikeState = /* @__PURE__ */ new Map();
|
|
2458
2675
|
/** Debounce delay in ms */
|
|
2459
2676
|
this.debounceDelay = 300;
|
|
2460
2677
|
const { interaction, feedManager, logger, ...restConfig } = config;
|
|
@@ -2468,453 +2685,353 @@ var OptimisticManager = class {
|
|
|
2468
2685
|
// PUBLIC API - ACTIONS
|
|
2469
2686
|
// ═══════════════════════════════════════════════════════════════
|
|
2470
2687
|
/**
|
|
2471
|
-
* Like
|
|
2688
|
+
* Like an item with optimistic update
|
|
2472
2689
|
*/
|
|
2473
|
-
async like(
|
|
2474
|
-
return this.performAction("like",
|
|
2690
|
+
async like(itemId) {
|
|
2691
|
+
return this.performAction("like", itemId, async () => {
|
|
2475
2692
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2476
|
-
await this.interaction.like(
|
|
2693
|
+
await this.interaction.like(itemId);
|
|
2477
2694
|
});
|
|
2478
2695
|
}
|
|
2479
2696
|
/**
|
|
2480
|
-
* Unlike
|
|
2697
|
+
* Unlike an item with optimistic update
|
|
2481
2698
|
*/
|
|
2482
|
-
async unlike(
|
|
2483
|
-
return this.performAction("unlike",
|
|
2699
|
+
async unlike(itemId) {
|
|
2700
|
+
return this.performAction("unlike", itemId, async () => {
|
|
2484
2701
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2485
|
-
await this.interaction.unlike(
|
|
2702
|
+
await this.interaction.unlike(itemId);
|
|
2486
2703
|
});
|
|
2487
2704
|
}
|
|
2488
2705
|
/**
|
|
2489
|
-
* Toggle like state with DEBOUNCE
|
|
2490
|
-
*
|
|
2491
|
-
* This method:
|
|
2492
|
-
* 1. Updates UI immediately (optimistic)
|
|
2493
|
-
* 2. Debounces API call - only sends after user stops clicking
|
|
2494
|
-
* 3. Sends final state to API after debounce delay
|
|
2495
|
-
*
|
|
2496
|
-
* Perfect for rapid tapping like TikTok/Instagram behavior.
|
|
2706
|
+
* Toggle like state with DEBOUNCE
|
|
2497
2707
|
*/
|
|
2498
|
-
toggleLike(
|
|
2499
|
-
const
|
|
2500
|
-
if (!
|
|
2501
|
-
this.logger?.warn(`[OptimisticManager]
|
|
2708
|
+
toggleLike(itemId) {
|
|
2709
|
+
const item = this.feedManager?.getItem(itemId);
|
|
2710
|
+
if (!item) {
|
|
2711
|
+
this.logger?.warn(`[OptimisticManager] Item not found for toggleLike: ${itemId}`);
|
|
2502
2712
|
return;
|
|
2503
2713
|
}
|
|
2504
|
-
const
|
|
2714
|
+
const currentIntended = this.intendedLikeState.get(itemId) ?? item.isLiked;
|
|
2715
|
+
const newIsLiked = !currentIntended;
|
|
2716
|
+
this.intendedLikeState.set(itemId, newIsLiked);
|
|
2505
2717
|
const likeDelta = newIsLiked ? 1 : -1;
|
|
2506
|
-
|
|
2718
|
+
const currentLikesFromStore = item.stats.likes;
|
|
2719
|
+
this.feedManager?.updateItem(itemId, {
|
|
2507
2720
|
isLiked: newIsLiked,
|
|
2508
|
-
stats: { ...
|
|
2721
|
+
stats: { ...item.stats, likes: Math.max(0, currentLikesFromStore + likeDelta) }
|
|
2509
2722
|
});
|
|
2510
|
-
|
|
2511
|
-
|
|
2723
|
+
const actionId = `like-debounce-${itemId}`;
|
|
2724
|
+
this.store.setState((state) => {
|
|
2725
|
+
const pendingActions = new Map(state.pendingActions);
|
|
2726
|
+
pendingActions.set(actionId, {
|
|
2727
|
+
id: actionId,
|
|
2728
|
+
videoId: itemId,
|
|
2729
|
+
// Keep property name 'videoId' in PendingAction for compatibility if needed, but using itemId value
|
|
2730
|
+
type: newIsLiked ? "like" : "unlike",
|
|
2731
|
+
status: "pending",
|
|
2732
|
+
timestamp: Date.now(),
|
|
2733
|
+
retryCount: 0,
|
|
2734
|
+
rollbackData: {
|
|
2735
|
+
isLiked: currentIntended,
|
|
2736
|
+
stats: { ...item.stats }
|
|
2737
|
+
}
|
|
2738
|
+
// Cast back to VideoItem if needed for contract
|
|
2739
|
+
});
|
|
2740
|
+
return { pendingActions, hasPending: true };
|
|
2741
|
+
});
|
|
2742
|
+
const existingTimer = this.likeDebounceTimers.get(itemId);
|
|
2512
2743
|
if (existingTimer) {
|
|
2513
2744
|
clearTimeout(existingTimer);
|
|
2514
2745
|
}
|
|
2515
2746
|
const timer = setTimeout(() => {
|
|
2516
|
-
this.likeDebounceTimers.delete(
|
|
2517
|
-
this.executeDebouncedLikeApi(
|
|
2747
|
+
this.likeDebounceTimers.delete(itemId);
|
|
2748
|
+
this.executeDebouncedLikeApi(itemId, actionId);
|
|
2518
2749
|
}, this.debounceDelay);
|
|
2519
|
-
this.likeDebounceTimers.set(
|
|
2750
|
+
this.likeDebounceTimers.set(itemId, timer);
|
|
2520
2751
|
}
|
|
2521
2752
|
/**
|
|
2522
|
-
* Execute
|
|
2523
|
-
* Reads current state from FeedManager to get final intended state
|
|
2753
|
+
* Execute API call after debounce delay
|
|
2524
2754
|
*/
|
|
2525
|
-
async executeDebouncedLikeApi(
|
|
2526
|
-
const
|
|
2527
|
-
if (
|
|
2528
|
-
|
|
2529
|
-
|
|
2755
|
+
async executeDebouncedLikeApi(itemId, actionId) {
|
|
2756
|
+
const finalIntended = this.intendedLikeState.get(itemId);
|
|
2757
|
+
if (finalIntended === void 0) {
|
|
2758
|
+
this.removePendingAction(actionId);
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2530
2761
|
try {
|
|
2531
|
-
if (!this.interaction)
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
if (shouldLike) {
|
|
2535
|
-
await this.interaction.like(videoId);
|
|
2762
|
+
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2763
|
+
if (finalIntended) {
|
|
2764
|
+
await this.interaction.like(itemId);
|
|
2536
2765
|
} else {
|
|
2537
|
-
await this.interaction.unlike(
|
|
2766
|
+
await this.interaction.unlike(itemId);
|
|
2767
|
+
}
|
|
2768
|
+
if (this.intendedLikeState.get(itemId) === finalIntended) {
|
|
2769
|
+
this.intendedLikeState.delete(itemId);
|
|
2538
2770
|
}
|
|
2539
|
-
this.logger?.debug(
|
|
2540
|
-
`[OptimisticManager] API success: ${videoId} ${shouldLike ? "liked" : "unliked"}`
|
|
2541
|
-
);
|
|
2542
2771
|
} catch (error) {
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2772
|
+
this.handleDebouncedApiError(itemId, finalIntended, error);
|
|
2773
|
+
} finally {
|
|
2774
|
+
this.removePendingAction(actionId);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Handle errors from debounced API calls
|
|
2779
|
+
*/
|
|
2780
|
+
handleDebouncedApiError(itemId, finalIntended, error) {
|
|
2781
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2782
|
+
this.logger?.error(`[OptimisticManager] API failed for ${itemId}`, err);
|
|
2783
|
+
if (!this.intendedLikeState.has(itemId)) {
|
|
2784
|
+
const currentItem = this.feedManager?.getItem(itemId);
|
|
2785
|
+
if (currentItem) {
|
|
2786
|
+
const rollbackIsLiked = !finalIntended;
|
|
2548
2787
|
const rollbackDelta = rollbackIsLiked ? 1 : -1;
|
|
2549
|
-
this.feedManager?.
|
|
2788
|
+
this.feedManager?.updateItem(itemId, {
|
|
2550
2789
|
isLiked: rollbackIsLiked,
|
|
2551
2790
|
stats: {
|
|
2552
|
-
...
|
|
2553
|
-
likes: Math.max(0,
|
|
2791
|
+
...currentItem.stats,
|
|
2792
|
+
likes: Math.max(0, currentItem.stats.likes + rollbackDelta)
|
|
2554
2793
|
}
|
|
2555
2794
|
});
|
|
2556
|
-
this.logger?.debug(
|
|
2557
|
-
`[OptimisticManager] Rolled back: ${videoId} isLiked=${rollbackIsLiked}`
|
|
2558
|
-
);
|
|
2559
2795
|
}
|
|
2560
2796
|
}
|
|
2561
2797
|
}
|
|
2562
2798
|
/**
|
|
2563
|
-
*
|
|
2564
|
-
* Legacy toggle that waits for API response
|
|
2799
|
+
* Remove a pending action by ID
|
|
2565
2800
|
*/
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
return
|
|
2571
|
-
|
|
2572
|
-
|
|
2801
|
+
removePendingAction(actionId) {
|
|
2802
|
+
this.store.setState((state) => {
|
|
2803
|
+
const pendingActions = new Map(state.pendingActions);
|
|
2804
|
+
pendingActions.delete(actionId);
|
|
2805
|
+
return {
|
|
2806
|
+
pendingActions,
|
|
2807
|
+
hasPending: pendingActions.size > 0
|
|
2808
|
+
};
|
|
2809
|
+
});
|
|
2573
2810
|
}
|
|
2574
2811
|
/**
|
|
2575
|
-
*
|
|
2812
|
+
* Toggle follow state
|
|
2576
2813
|
*/
|
|
2577
|
-
async
|
|
2578
|
-
|
|
2814
|
+
async toggleFollow(itemId) {
|
|
2815
|
+
const item = this.feedManager?.getItem(itemId);
|
|
2816
|
+
if (!item) return false;
|
|
2817
|
+
return item.isFollowing ? this.unfollow(itemId) : this.follow(itemId);
|
|
2818
|
+
}
|
|
2819
|
+
async follow(itemId) {
|
|
2820
|
+
return this.performAction("follow", itemId, async () => {
|
|
2579
2821
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2580
|
-
await this.interaction.follow(
|
|
2822
|
+
await this.interaction.follow(itemId);
|
|
2581
2823
|
});
|
|
2582
2824
|
}
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
*/
|
|
2586
|
-
async unfollow(videoId) {
|
|
2587
|
-
return this.performAction("unfollow", videoId, async () => {
|
|
2825
|
+
async unfollow(itemId) {
|
|
2826
|
+
return this.performAction("unfollow", itemId, async () => {
|
|
2588
2827
|
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2589
|
-
await this.interaction.unfollow(
|
|
2828
|
+
await this.interaction.unfollow(itemId);
|
|
2590
2829
|
});
|
|
2591
2830
|
}
|
|
2831
|
+
addEventListener(listener) {
|
|
2832
|
+
this.eventListeners.add(listener);
|
|
2833
|
+
return () => this.removeEventListener(listener);
|
|
2834
|
+
}
|
|
2835
|
+
removeEventListener(listener) {
|
|
2836
|
+
this.eventListeners.delete(listener);
|
|
2837
|
+
}
|
|
2592
2838
|
/**
|
|
2593
|
-
*
|
|
2839
|
+
* Reset all optimistic state
|
|
2594
2840
|
*/
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
if (!video) {
|
|
2598
|
-
this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
|
|
2599
|
-
return false;
|
|
2600
|
-
}
|
|
2601
|
-
return video.isFollowing ? this.unfollow(videoId) : this.follow(videoId);
|
|
2841
|
+
reset() {
|
|
2842
|
+
this.store.setState(createInitialState5());
|
|
2602
2843
|
}
|
|
2603
2844
|
// ═══════════════════════════════════════════════════════════════
|
|
2604
|
-
//
|
|
2845
|
+
// STATE MANAGEMENT
|
|
2605
2846
|
// ═══════════════════════════════════════════════════════════════
|
|
2606
|
-
/**
|
|
2607
|
-
* Get all pending actions
|
|
2608
|
-
*/
|
|
2609
2847
|
getPendingActions() {
|
|
2610
2848
|
return [...this.store.getState().pendingActions.values()];
|
|
2611
2849
|
}
|
|
2612
|
-
|
|
2613
|
-
* Check if there's a pending action for a video
|
|
2614
|
-
* Only returns true for actions with status 'pending' (not 'failed')
|
|
2615
|
-
*/
|
|
2616
|
-
hasPendingAction(videoId, type) {
|
|
2850
|
+
hasPendingAction(itemId, type) {
|
|
2617
2851
|
const actions = this.store.getState().pendingActions;
|
|
2618
2852
|
for (const action of actions.values()) {
|
|
2619
|
-
if (action.videoId ===
|
|
2853
|
+
if (action.videoId === itemId && action.status === "pending" && (!type || action.type === type)) {
|
|
2620
2854
|
return true;
|
|
2621
2855
|
}
|
|
2622
2856
|
}
|
|
2623
2857
|
return false;
|
|
2624
2858
|
}
|
|
2625
|
-
/**
|
|
2626
|
-
* Get failed actions queue
|
|
2627
|
-
*/
|
|
2628
2859
|
getFailedQueue() {
|
|
2629
2860
|
const state = this.store.getState();
|
|
2630
2861
|
return state.failedQueue.map((id) => state.pendingActions.get(id)).filter((a) => a !== void 0);
|
|
2631
2862
|
}
|
|
2632
|
-
/**
|
|
2633
|
-
* Manually retry failed actions
|
|
2634
|
-
*/
|
|
2635
2863
|
async retryFailed() {
|
|
2636
2864
|
const state = this.store.getState();
|
|
2637
2865
|
if (state.isRetrying || state.failedQueue.length === 0) return;
|
|
2638
2866
|
this.store.setState({ isRetrying: true });
|
|
2639
|
-
const
|
|
2640
|
-
for (const
|
|
2641
|
-
const action =
|
|
2642
|
-
if (action
|
|
2867
|
+
const queue = [...state.failedQueue];
|
|
2868
|
+
for (const id of queue) {
|
|
2869
|
+
const action = this.store.getState().pendingActions.get(id);
|
|
2870
|
+
if (!action) continue;
|
|
2871
|
+
if (action.retryCount < this.config.maxRetries) {
|
|
2643
2872
|
await this.retryAction(action);
|
|
2873
|
+
} else {
|
|
2874
|
+
this.emit({ type: "retryExhausted", action });
|
|
2644
2875
|
}
|
|
2645
2876
|
}
|
|
2646
2877
|
this.store.setState({ isRetrying: false });
|
|
2647
2878
|
}
|
|
2648
|
-
/**
|
|
2649
|
-
* Clear all failed actions
|
|
2650
|
-
*/
|
|
2651
2879
|
clearFailed() {
|
|
2652
2880
|
const state = this.store.getState();
|
|
2653
|
-
const
|
|
2654
|
-
for (const
|
|
2655
|
-
const action =
|
|
2881
|
+
const newPending = new Map(state.pendingActions);
|
|
2882
|
+
for (const id of state.failedQueue) {
|
|
2883
|
+
const action = newPending.get(id);
|
|
2656
2884
|
if (action) {
|
|
2657
2885
|
this.applyRollback(action);
|
|
2658
|
-
|
|
2886
|
+
newPending.delete(id);
|
|
2659
2887
|
}
|
|
2660
2888
|
}
|
|
2661
2889
|
this.store.setState({
|
|
2662
|
-
pendingActions:
|
|
2890
|
+
pendingActions: newPending,
|
|
2663
2891
|
failedQueue: [],
|
|
2664
|
-
hasPending:
|
|
2892
|
+
hasPending: newPending.size > 0
|
|
2665
2893
|
});
|
|
2666
2894
|
}
|
|
2667
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2668
|
-
// PUBLIC API - LIFECYCLE
|
|
2669
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2670
|
-
/**
|
|
2671
|
-
* Reset manager state
|
|
2672
|
-
*/
|
|
2673
|
-
reset() {
|
|
2674
|
-
this.cancelRetryTimer();
|
|
2675
|
-
this.store.setState(createInitialState5());
|
|
2676
|
-
}
|
|
2677
|
-
/**
|
|
2678
|
-
* Destroy manager and cleanup
|
|
2679
|
-
*/
|
|
2680
2895
|
destroy() {
|
|
2681
|
-
this.
|
|
2896
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
2682
2897
|
this.eventListeners.clear();
|
|
2683
|
-
this.store.setState(createInitialState5());
|
|
2684
|
-
}
|
|
2685
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2686
|
-
// PUBLIC API - EVENTS
|
|
2687
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2688
|
-
/**
|
|
2689
|
-
* Add event listener
|
|
2690
|
-
*/
|
|
2691
|
-
addEventListener(listener) {
|
|
2692
|
-
this.eventListeners.add(listener);
|
|
2693
|
-
return () => this.eventListeners.delete(listener);
|
|
2694
|
-
}
|
|
2695
|
-
/**
|
|
2696
|
-
* Remove event listener
|
|
2697
|
-
*/
|
|
2698
|
-
removeEventListener(listener) {
|
|
2699
|
-
this.eventListeners.delete(listener);
|
|
2700
2898
|
}
|
|
2701
2899
|
// ═══════════════════════════════════════════════════════════════
|
|
2702
|
-
// PRIVATE
|
|
2900
|
+
// PRIVATE UTILS
|
|
2703
2901
|
// ═══════════════════════════════════════════════════════════════
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2902
|
+
emit(event) {
|
|
2903
|
+
for (const listener of this.eventListeners) {
|
|
2904
|
+
try {
|
|
2905
|
+
listener(event);
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2908
|
+
this.logger?.error("[OptimisticManager] Listener failed", error);
|
|
2909
|
+
}
|
|
2711
2910
|
}
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2911
|
+
}
|
|
2912
|
+
async performAction(type, itemId, apiCall) {
|
|
2913
|
+
const item = this.feedManager?.getItem(itemId);
|
|
2914
|
+
if (!item) return false;
|
|
2915
|
+
if (this.hasPendingAction(itemId, type)) {
|
|
2715
2916
|
return false;
|
|
2716
2917
|
}
|
|
2717
2918
|
const action = {
|
|
2718
2919
|
id: generateActionId(),
|
|
2719
2920
|
type,
|
|
2720
|
-
videoId,
|
|
2721
|
-
rollbackData: this.createRollbackData(type,
|
|
2921
|
+
videoId: itemId,
|
|
2922
|
+
rollbackData: this.createRollbackData(type, item),
|
|
2722
2923
|
timestamp: Date.now(),
|
|
2723
2924
|
status: "pending",
|
|
2724
2925
|
retryCount: 0
|
|
2725
2926
|
};
|
|
2726
2927
|
this.addPendingAction(action);
|
|
2727
|
-
this.
|
|
2728
|
-
this.
|
|
2729
|
-
this.logger?.debug(`[OptimisticManager] Action started: ${type} ${videoId}`);
|
|
2928
|
+
this.applyOptimisticUpdate(type, item);
|
|
2929
|
+
this.emit({ type: "actionStart", action });
|
|
2730
2930
|
try {
|
|
2731
2931
|
await apiCall();
|
|
2732
2932
|
this.markActionSuccess(action.id);
|
|
2733
|
-
this.emitEvent({ type: "actionSuccess", action: { ...action, status: "success" } });
|
|
2734
|
-
this.logger?.debug(`[OptimisticManager] Action succeeded: ${type} ${videoId}`);
|
|
2735
2933
|
return true;
|
|
2736
2934
|
} catch (error) {
|
|
2737
2935
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
2738
2936
|
this.markActionFailed(action.id, err.message);
|
|
2739
2937
|
this.applyRollback(action);
|
|
2740
|
-
this.
|
|
2741
|
-
this.emitEvent({ type: "actionFailed", action: { ...action, status: "failed" }, error: err });
|
|
2742
|
-
this.logger?.warn(`[OptimisticManager] Action failed: ${type} ${videoId}`, {
|
|
2743
|
-
error: err.message
|
|
2744
|
-
});
|
|
2745
|
-
if (this.config.autoRetry) {
|
|
2746
|
-
this.scheduleRetry();
|
|
2747
|
-
}
|
|
2938
|
+
if (this.config.autoRetry) this.scheduleRetry();
|
|
2748
2939
|
return false;
|
|
2749
2940
|
}
|
|
2750
2941
|
}
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
createRollbackData(type, video) {
|
|
2755
|
-
switch (type) {
|
|
2756
|
-
case "like":
|
|
2757
|
-
return {
|
|
2758
|
-
isLiked: video.isLiked,
|
|
2759
|
-
stats: { ...video.stats }
|
|
2760
|
-
};
|
|
2761
|
-
case "unlike":
|
|
2762
|
-
return {
|
|
2763
|
-
isLiked: video.isLiked,
|
|
2764
|
-
stats: { ...video.stats }
|
|
2765
|
-
};
|
|
2766
|
-
case "follow":
|
|
2767
|
-
case "unfollow":
|
|
2768
|
-
return {
|
|
2769
|
-
isFollowing: video.isFollowing
|
|
2770
|
-
};
|
|
2771
|
-
default:
|
|
2772
|
-
return {};
|
|
2942
|
+
createRollbackData(type, item) {
|
|
2943
|
+
if (type === "like" || type === "unlike") {
|
|
2944
|
+
return { isLiked: item.isLiked, stats: { ...item.stats } };
|
|
2773
2945
|
}
|
|
2946
|
+
return { isFollowing: item.isFollowing };
|
|
2774
2947
|
}
|
|
2775
|
-
|
|
2776
|
-
* Apply optimistic update to feed
|
|
2777
|
-
*/
|
|
2778
|
-
applyOptimisticUpdate(type, video) {
|
|
2948
|
+
applyOptimisticUpdate(type, item) {
|
|
2779
2949
|
if (!this.feedManager) return;
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
this.feedManager.updateVideo(video.id, {
|
|
2795
|
-
isFollowing: true
|
|
2796
|
-
});
|
|
2797
|
-
break;
|
|
2798
|
-
case "unfollow":
|
|
2799
|
-
this.feedManager.updateVideo(video.id, {
|
|
2800
|
-
isFollowing: false
|
|
2801
|
-
});
|
|
2802
|
-
break;
|
|
2950
|
+
if (type === "like") {
|
|
2951
|
+
this.feedManager.updateItem(item.id, {
|
|
2952
|
+
isLiked: true,
|
|
2953
|
+
stats: { ...item.stats, likes: item.stats.likes + 1 }
|
|
2954
|
+
});
|
|
2955
|
+
} else if (type === "unlike") {
|
|
2956
|
+
this.feedManager.updateItem(item.id, {
|
|
2957
|
+
isLiked: false,
|
|
2958
|
+
stats: { ...item.stats, likes: Math.max(0, item.stats.likes - 1) }
|
|
2959
|
+
});
|
|
2960
|
+
} else if (type === "follow") {
|
|
2961
|
+
this.feedManager.updateItem(item.id, { isFollowing: true });
|
|
2962
|
+
} else if (type === "unfollow") {
|
|
2963
|
+
this.feedManager.updateItem(item.id, { isFollowing: false });
|
|
2803
2964
|
}
|
|
2804
2965
|
}
|
|
2805
|
-
/**
|
|
2806
|
-
* Apply rollback
|
|
2807
|
-
*/
|
|
2808
2966
|
applyRollback(action) {
|
|
2809
|
-
|
|
2810
|
-
this.
|
|
2967
|
+
this.feedManager?.updateItem(action.videoId, action.rollbackData);
|
|
2968
|
+
this.emit({ type: "actionRollback", action });
|
|
2811
2969
|
}
|
|
2812
|
-
/**
|
|
2813
|
-
* Add pending action to store
|
|
2814
|
-
*/
|
|
2815
2970
|
addPendingAction(action) {
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
pendingActions: newPendingActions,
|
|
2821
|
-
hasPending: true
|
|
2971
|
+
this.store.setState((state) => {
|
|
2972
|
+
const m = new Map(state.pendingActions);
|
|
2973
|
+
m.set(action.id, action);
|
|
2974
|
+
return { pendingActions: m, hasPending: true };
|
|
2822
2975
|
});
|
|
2823
2976
|
}
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
pendingActions:
|
|
2834
|
-
failedQueue: newFailedQueue,
|
|
2835
|
-
hasPending: newPendingActions.size > 0
|
|
2977
|
+
markActionSuccess(id) {
|
|
2978
|
+
this.store.setState((state) => {
|
|
2979
|
+
const m = new Map(state.pendingActions);
|
|
2980
|
+
const action = m.get(id);
|
|
2981
|
+
if (action) {
|
|
2982
|
+
this.emit({ type: "actionSuccess", action: { ...action, status: "success" } });
|
|
2983
|
+
}
|
|
2984
|
+
m.delete(id);
|
|
2985
|
+
const q = state.failedQueue.filter((x) => x !== id);
|
|
2986
|
+
return { pendingActions: m, failedQueue: q, hasPending: m.size > 0 };
|
|
2836
2987
|
});
|
|
2837
2988
|
}
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
this.store.setState({
|
|
2853
|
-
pendingActions: newPendingActions,
|
|
2854
|
-
failedQueue: newFailedQueue
|
|
2989
|
+
markActionFailed(id, error) {
|
|
2990
|
+
this.store.setState((state) => {
|
|
2991
|
+
const m = new Map(state.pendingActions);
|
|
2992
|
+
const a = m.get(id);
|
|
2993
|
+
if (!a) return state;
|
|
2994
|
+
const failedAction = { ...a, status: "failed", error };
|
|
2995
|
+
m.set(id, failedAction);
|
|
2996
|
+
const q = state.failedQueue.includes(id) ? state.failedQueue : [...state.failedQueue, id];
|
|
2997
|
+
this.emit({
|
|
2998
|
+
type: "actionFailed",
|
|
2999
|
+
action: failedAction,
|
|
3000
|
+
error: new Error(error)
|
|
3001
|
+
});
|
|
3002
|
+
return { pendingActions: m, failedQueue: q };
|
|
2855
3003
|
});
|
|
2856
3004
|
}
|
|
2857
|
-
/**
|
|
2858
|
-
* Retry a failed action
|
|
2859
|
-
*/
|
|
2860
3005
|
async retryAction(action) {
|
|
2861
|
-
this.
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
error: void 0
|
|
2869
|
-
};
|
|
2870
|
-
newPendingActions.set(action.id, updatedAction);
|
|
2871
|
-
const newFailedQueue = state.failedQueue.filter((id) => id !== action.id);
|
|
2872
|
-
this.store.setState({
|
|
2873
|
-
pendingActions: newPendingActions,
|
|
2874
|
-
failedQueue: newFailedQueue
|
|
3006
|
+
this.store.setState((state) => {
|
|
3007
|
+
const m = new Map(state.pendingActions);
|
|
3008
|
+
const a = m.get(action.id);
|
|
3009
|
+
if (a) {
|
|
3010
|
+
m.set(action.id, { ...a, retryCount: a.retryCount + 1 });
|
|
3011
|
+
}
|
|
3012
|
+
return { pendingActions: m };
|
|
2875
3013
|
});
|
|
2876
|
-
const
|
|
2877
|
-
if (
|
|
2878
|
-
|
|
2879
|
-
}
|
|
3014
|
+
const updatedAction = this.store.getState().pendingActions.get(action.id);
|
|
3015
|
+
if (!updatedAction) return;
|
|
3016
|
+
this.emit({ type: "retryStart", actionId: updatedAction.id });
|
|
2880
3017
|
try {
|
|
2881
|
-
await this.
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
if (updatedAction.
|
|
2887
|
-
this.
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
this.
|
|
3018
|
+
if (updatedAction.type === "like") await this.interaction?.like(updatedAction.videoId);
|
|
3019
|
+
else if (updatedAction.type === "unlike")
|
|
3020
|
+
await this.interaction?.unlike(updatedAction.videoId);
|
|
3021
|
+
else if (updatedAction.type === "follow")
|
|
3022
|
+
await this.interaction?.follow(updatedAction.videoId);
|
|
3023
|
+
else if (updatedAction.type === "unfollow")
|
|
3024
|
+
await this.interaction?.unfollow(updatedAction.videoId);
|
|
3025
|
+
const currentItem = this.feedManager?.getItem(updatedAction.videoId);
|
|
3026
|
+
if (currentItem) {
|
|
3027
|
+
this.applyOptimisticUpdate(updatedAction.type, currentItem);
|
|
2891
3028
|
}
|
|
2892
|
-
this.
|
|
2893
|
-
}
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
* Execute API call based on action type
|
|
2897
|
-
*/
|
|
2898
|
-
async executeApiCall(type, videoId) {
|
|
2899
|
-
if (!this.interaction) throw new Error("No interaction adapter");
|
|
2900
|
-
switch (type) {
|
|
2901
|
-
case "like":
|
|
2902
|
-
await this.interaction.like(videoId);
|
|
2903
|
-
break;
|
|
2904
|
-
case "unlike":
|
|
2905
|
-
await this.interaction.unlike(videoId);
|
|
2906
|
-
break;
|
|
2907
|
-
case "follow":
|
|
2908
|
-
await this.interaction.follow(videoId);
|
|
2909
|
-
break;
|
|
2910
|
-
case "unfollow":
|
|
2911
|
-
await this.interaction.unfollow(videoId);
|
|
2912
|
-
break;
|
|
3029
|
+
this.markActionSuccess(updatedAction.id);
|
|
3030
|
+
} catch (e) {
|
|
3031
|
+
this.markActionFailed(updatedAction.id, String(e));
|
|
3032
|
+
this.applyRollback(updatedAction);
|
|
2913
3033
|
}
|
|
2914
3034
|
}
|
|
2915
|
-
/**
|
|
2916
|
-
* Schedule retry of failed actions
|
|
2917
|
-
*/
|
|
2918
3035
|
scheduleRetry() {
|
|
2919
3036
|
if (this.retryTimer) return;
|
|
2920
3037
|
this.retryTimer = setTimeout(() => {
|
|
@@ -2922,30 +3039,6 @@ var OptimisticManager = class {
|
|
|
2922
3039
|
this.retryFailed();
|
|
2923
3040
|
}, this.config.retryDelayMs);
|
|
2924
3041
|
}
|
|
2925
|
-
/**
|
|
2926
|
-
* Cancel retry timer
|
|
2927
|
-
*/
|
|
2928
|
-
cancelRetryTimer() {
|
|
2929
|
-
if (this.retryTimer) {
|
|
2930
|
-
clearTimeout(this.retryTimer);
|
|
2931
|
-
this.retryTimer = null;
|
|
2932
|
-
}
|
|
2933
|
-
}
|
|
2934
|
-
/**
|
|
2935
|
-
* Emit event to all listeners
|
|
2936
|
-
*/
|
|
2937
|
-
emitEvent(event) {
|
|
2938
|
-
for (const listener of this.eventListeners) {
|
|
2939
|
-
try {
|
|
2940
|
-
listener(event);
|
|
2941
|
-
} catch (err) {
|
|
2942
|
-
this.logger?.error(
|
|
2943
|
-
"[OptimisticManager] Event listener error",
|
|
2944
|
-
err instanceof Error ? err : new Error(String(err))
|
|
2945
|
-
);
|
|
2946
|
-
}
|
|
2947
|
-
}
|
|
2948
|
-
}
|
|
2949
3042
|
};
|
|
2950
3043
|
|
|
2951
3044
|
// src/comment/types.ts
|
|
@@ -3369,8 +3462,8 @@ var CommentManager = class {
|
|
|
3369
3462
|
// PRIVATE - OPTIMISTIC UPDATE HELPERS
|
|
3370
3463
|
// ═══════════════════════════════════════════════════════════════
|
|
3371
3464
|
addOptimisticComment(videoId, comment) {
|
|
3372
|
-
const
|
|
3373
|
-
|
|
3465
|
+
const state = this.store.getState();
|
|
3466
|
+
const videoState = state.byVideoId.get(videoId) ?? createInitialVideoCommentState();
|
|
3374
3467
|
const commentsById = new Map(videoState.commentsById);
|
|
3375
3468
|
commentsById.set(comment.id, comment);
|
|
3376
3469
|
const displayOrder = [comment.id, ...videoState.displayOrder];
|
|
@@ -3393,14 +3486,19 @@ var CommentManager = class {
|
|
|
3393
3486
|
});
|
|
3394
3487
|
}
|
|
3395
3488
|
replaceOptimisticComment(videoId, optimisticId, realComment) {
|
|
3396
|
-
const
|
|
3397
|
-
|
|
3489
|
+
const state = this.store.getState();
|
|
3490
|
+
const videoState = state.byVideoId.get(videoId) ?? createInitialVideoCommentState();
|
|
3398
3491
|
const commentsById = new Map(videoState.commentsById);
|
|
3399
3492
|
commentsById.delete(optimisticId);
|
|
3400
3493
|
commentsById.set(realComment.id, realComment);
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3494
|
+
let displayOrder;
|
|
3495
|
+
if (videoState.displayOrder.includes(optimisticId)) {
|
|
3496
|
+
displayOrder = videoState.displayOrder.map(
|
|
3497
|
+
(id) => id === optimisticId ? realComment.id : id
|
|
3498
|
+
);
|
|
3499
|
+
} else {
|
|
3500
|
+
displayOrder = [realComment.id, ...videoState.displayOrder];
|
|
3501
|
+
}
|
|
3404
3502
|
this.updateVideoState(videoId, { commentsById, displayOrder });
|
|
3405
3503
|
}
|
|
3406
3504
|
addOptimisticReply(videoId, parentId, reply) {
|
|
@@ -3566,4 +3664,303 @@ var CommentManager = class {
|
|
|
3566
3664
|
}
|
|
3567
3665
|
};
|
|
3568
3666
|
|
|
3569
|
-
|
|
3667
|
+
// src/playlist/types.ts
|
|
3668
|
+
var DEFAULT_PLAYLIST_CONFIG = {
|
|
3669
|
+
metadataWindowSize: 10
|
|
3670
|
+
};
|
|
3671
|
+
|
|
3672
|
+
// src/playlist/PlaylistManager.ts
|
|
3673
|
+
var createInitialState6 = () => ({
|
|
3674
|
+
playlist: null,
|
|
3675
|
+
currentIndex: 0,
|
|
3676
|
+
items: [],
|
|
3677
|
+
loading: false,
|
|
3678
|
+
error: null
|
|
3679
|
+
});
|
|
3680
|
+
var PlaylistManager = class {
|
|
3681
|
+
constructor(dataSource, config = {}, governor, storage) {
|
|
3682
|
+
this.dataSource = dataSource;
|
|
3683
|
+
this.governor = governor;
|
|
3684
|
+
this.storage = storage;
|
|
3685
|
+
/**
|
|
3686
|
+
* Internal cache of full metadata items.
|
|
3687
|
+
* Items in store.items may be minified to save memory.
|
|
3688
|
+
*/
|
|
3689
|
+
this.fullMetadataItems = [];
|
|
3690
|
+
this.config = { ...DEFAULT_PLAYLIST_CONFIG, ...config };
|
|
3691
|
+
this.store = createStore(createInitialState6);
|
|
3692
|
+
if (this.governor) {
|
|
3693
|
+
this.governorUnsubscribe = this.governor.addEventListener((event) => {
|
|
3694
|
+
if (event.type === "focusChange") {
|
|
3695
|
+
this.jumpTo(event.index);
|
|
3696
|
+
}
|
|
3697
|
+
});
|
|
3698
|
+
}
|
|
3699
|
+
this.store.subscribe((state, prevState) => {
|
|
3700
|
+
if (state.currentIndex !== prevState.currentIndex || state.playlist !== prevState.playlist) {
|
|
3701
|
+
this.updateMetadataWindow(state.currentIndex);
|
|
3702
|
+
}
|
|
3703
|
+
});
|
|
3704
|
+
}
|
|
3705
|
+
/**
|
|
3706
|
+
* Load a playlist by ID
|
|
3707
|
+
*/
|
|
3708
|
+
/**
|
|
3709
|
+
* Generates a cache key for a specific playlist
|
|
3710
|
+
*/
|
|
3711
|
+
getCacheKey(id) {
|
|
3712
|
+
return `sv-playlist-data-${id}`;
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* Load playlist data, using cache for an immediate render before network finishes.
|
|
3716
|
+
*/
|
|
3717
|
+
async loadPlaylist(id) {
|
|
3718
|
+
if (!this.dataSource) {
|
|
3719
|
+
this.store.setState({ error: new Error("No playlist data source provided") });
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
const cacheKey = this.getCacheKey(id);
|
|
3723
|
+
try {
|
|
3724
|
+
if (this.storage) {
|
|
3725
|
+
const cachedPlaylist = await this.storage.get(cacheKey);
|
|
3726
|
+
if (cachedPlaylist) {
|
|
3727
|
+
this.setPlaylist(cachedPlaylist);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
} catch {
|
|
3731
|
+
}
|
|
3732
|
+
this.store.setState({ loading: true, error: null });
|
|
3733
|
+
try {
|
|
3734
|
+
const playlist = await this.dataSource.fetchPlaylist(id);
|
|
3735
|
+
this.setPlaylist(playlist);
|
|
3736
|
+
if (this.storage) {
|
|
3737
|
+
const itemsToCache = playlist.items.slice(0, Math.max(this.config.metadataWindowSize, 5));
|
|
3738
|
+
const cachePayload = { ...playlist, items: itemsToCache };
|
|
3739
|
+
this.storage.set(cacheKey, cachePayload).catch(() => {
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
} catch (error) {
|
|
3743
|
+
if (!this.store.getState().playlist) {
|
|
3744
|
+
this.store.setState({
|
|
3745
|
+
loading: false,
|
|
3746
|
+
error: error instanceof Error ? error : new Error("Failed to load playlist")
|
|
3747
|
+
});
|
|
3748
|
+
} else {
|
|
3749
|
+
this.store.setState({ loading: false });
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
/**
|
|
3754
|
+
* Set playlist data directly
|
|
3755
|
+
*/
|
|
3756
|
+
setPlaylist(playlist) {
|
|
3757
|
+
this.fullMetadataItems = [...playlist.items];
|
|
3758
|
+
const { metadataWindowSize } = this.config;
|
|
3759
|
+
const end = Math.min(this.fullMetadataItems.length, metadataWindowSize);
|
|
3760
|
+
const windowedItems = this.fullMetadataItems.map(
|
|
3761
|
+
(item, index) => index < end ? item : this.minifyItem(item)
|
|
3762
|
+
);
|
|
3763
|
+
this.store.setState({
|
|
3764
|
+
playlist,
|
|
3765
|
+
currentIndex: 0,
|
|
3766
|
+
items: windowedItems,
|
|
3767
|
+
loading: false,
|
|
3768
|
+
error: null
|
|
3769
|
+
});
|
|
3770
|
+
}
|
|
3771
|
+
/**
|
|
3772
|
+
* Navigate to next item
|
|
3773
|
+
*/
|
|
3774
|
+
next() {
|
|
3775
|
+
const { currentIndex, items } = this.store.getState();
|
|
3776
|
+
if (currentIndex < items.length - 1) {
|
|
3777
|
+
this.store.setState({ currentIndex: currentIndex + 1 });
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
/**
|
|
3781
|
+
* Navigate to previous item
|
|
3782
|
+
*/
|
|
3783
|
+
prev() {
|
|
3784
|
+
const { currentIndex } = this.store.getState();
|
|
3785
|
+
if (currentIndex > 0) {
|
|
3786
|
+
this.store.setState({ currentIndex: currentIndex - 1 });
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
/**
|
|
3790
|
+
* Jump to specific index
|
|
3791
|
+
*/
|
|
3792
|
+
jumpTo(index) {
|
|
3793
|
+
const { items } = this.store.getState();
|
|
3794
|
+
if (index >= 0 && index < items.length) {
|
|
3795
|
+
this.store.setState({ currentIndex: index });
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
/**
|
|
3799
|
+
* Reset state
|
|
3800
|
+
*/
|
|
3801
|
+
reset() {
|
|
3802
|
+
this.fullMetadataItems = [];
|
|
3803
|
+
this.store.setState(createInitialState6());
|
|
3804
|
+
}
|
|
3805
|
+
/**
|
|
3806
|
+
* Get the full (un-minified) list of items in the current playlist.
|
|
3807
|
+
*
|
|
3808
|
+
* Unlike `store.items` (which applies the sliding window and may contain
|
|
3809
|
+
* minified items), this always returns the complete metadata for every item.
|
|
3810
|
+
* Used by PlaylistFeedAdapter to ensure FeedManager receives playable data.
|
|
3811
|
+
*/
|
|
3812
|
+
getFullItems() {
|
|
3813
|
+
return [...this.fullMetadataItems];
|
|
3814
|
+
}
|
|
3815
|
+
/**
|
|
3816
|
+
* Destroy the manager
|
|
3817
|
+
*/
|
|
3818
|
+
destroy() {
|
|
3819
|
+
this.governorUnsubscribe?.();
|
|
3820
|
+
this.reset();
|
|
3821
|
+
}
|
|
3822
|
+
/**
|
|
3823
|
+
* Update the sliding window of full metadata items.
|
|
3824
|
+
* Items outside the window are minified to save memory.
|
|
3825
|
+
*/
|
|
3826
|
+
updateMetadataWindow(currentIndex) {
|
|
3827
|
+
const { metadataWindowSize } = this.config;
|
|
3828
|
+
const items = this.fullMetadataItems;
|
|
3829
|
+
if (items.length === 0) return;
|
|
3830
|
+
const halfWindow = Math.floor(metadataWindowSize / 2);
|
|
3831
|
+
let start = Math.max(0, currentIndex - halfWindow);
|
|
3832
|
+
const end = Math.min(items.length, start + metadataWindowSize);
|
|
3833
|
+
if (end === items.length) {
|
|
3834
|
+
start = Math.max(0, end - metadataWindowSize);
|
|
3835
|
+
}
|
|
3836
|
+
const windowedItems = items.map((item, index) => {
|
|
3837
|
+
if (index >= start && index < end) {
|
|
3838
|
+
return item;
|
|
3839
|
+
}
|
|
3840
|
+
return this.minifyItem(item);
|
|
3841
|
+
});
|
|
3842
|
+
this.store.setState({ items: windowedItems });
|
|
3843
|
+
}
|
|
3844
|
+
/**
|
|
3845
|
+
* Create a minified version of a ContentItem to save memory.
|
|
3846
|
+
* Keeps only essential fields for identification and basic UI.
|
|
3847
|
+
*/
|
|
3848
|
+
minifyItem(item) {
|
|
3849
|
+
return {
|
|
3850
|
+
id: item.id,
|
|
3851
|
+
type: item.type,
|
|
3852
|
+
author: {
|
|
3853
|
+
id: item.author.id,
|
|
3854
|
+
name: item.author.name
|
|
3855
|
+
},
|
|
3856
|
+
// Essential stats for basic UI if needed
|
|
3857
|
+
stats: {
|
|
3858
|
+
likes: 0,
|
|
3859
|
+
comments: 0,
|
|
3860
|
+
shares: 0
|
|
3861
|
+
},
|
|
3862
|
+
isLiked: false,
|
|
3863
|
+
isFollowing: false,
|
|
3864
|
+
createdAt: ""
|
|
3865
|
+
};
|
|
3866
|
+
}
|
|
3867
|
+
};
|
|
3868
|
+
|
|
3869
|
+
// src/playlist/PlaylistCollectionManager.ts
|
|
3870
|
+
var createInitialState7 = () => ({
|
|
3871
|
+
playlists: [],
|
|
3872
|
+
loading: false,
|
|
3873
|
+
cursor: null,
|
|
3874
|
+
hasMore: true,
|
|
3875
|
+
error: null
|
|
3876
|
+
});
|
|
3877
|
+
var _PlaylistCollectionManager = class _PlaylistCollectionManager {
|
|
3878
|
+
constructor(dataSource, storage) {
|
|
3879
|
+
this.dataSource = dataSource;
|
|
3880
|
+
this.storage = storage;
|
|
3881
|
+
this.store = createStore(createInitialState7);
|
|
3882
|
+
}
|
|
3883
|
+
/**
|
|
3884
|
+
* Hydrate collection from cache
|
|
3885
|
+
*/
|
|
3886
|
+
async hydrateFromCache() {
|
|
3887
|
+
if (!this.storage) return;
|
|
3888
|
+
try {
|
|
3889
|
+
const cached = await this.storage.get(_PlaylistCollectionManager.CACHE_KEY);
|
|
3890
|
+
if (Array.isArray(cached?.playlists)) {
|
|
3891
|
+
this.store.setState({
|
|
3892
|
+
playlists: cached.playlists,
|
|
3893
|
+
cursor: cached.cursor,
|
|
3894
|
+
hasMore: cached.hasMore
|
|
3895
|
+
});
|
|
3896
|
+
}
|
|
3897
|
+
} catch {
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
/**
|
|
3901
|
+
* Update cache with current state
|
|
3902
|
+
*/
|
|
3903
|
+
updateCache() {
|
|
3904
|
+
if (!this.storage) return;
|
|
3905
|
+
const { playlists, cursor, hasMore } = this.store.getState();
|
|
3906
|
+
this.storage.set(_PlaylistCollectionManager.CACHE_KEY, { playlists, cursor, hasMore }).catch(() => {
|
|
3907
|
+
});
|
|
3908
|
+
}
|
|
3909
|
+
/**
|
|
3910
|
+
* Load more playlists (pagination)
|
|
3911
|
+
*/
|
|
3912
|
+
async loadMore() {
|
|
3913
|
+
const { loading, cursor, hasMore } = this.store.getState();
|
|
3914
|
+
if (loading || !hasMore || !this.dataSource) return;
|
|
3915
|
+
this.store.setState({ loading: true, error: null });
|
|
3916
|
+
try {
|
|
3917
|
+
const response = await this.dataSource.fetchPlaylistCollection(cursor || void 0);
|
|
3918
|
+
this.store.setState((state) => ({
|
|
3919
|
+
playlists: [...state.playlists, ...response.playlists],
|
|
3920
|
+
cursor: response.nextCursor,
|
|
3921
|
+
hasMore: response.hasMore,
|
|
3922
|
+
loading: false
|
|
3923
|
+
}));
|
|
3924
|
+
this.updateCache();
|
|
3925
|
+
} catch (error) {
|
|
3926
|
+
this.store.setState({
|
|
3927
|
+
loading: false,
|
|
3928
|
+
error: error instanceof Error ? error : new Error("Failed to load playlist collection")
|
|
3929
|
+
});
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
/**
|
|
3933
|
+
* Refresh the collection (reset and re-fetch)
|
|
3934
|
+
*/
|
|
3935
|
+
async refresh() {
|
|
3936
|
+
if (!this.dataSource) return;
|
|
3937
|
+
this.store.setState({ ...createInitialState7(), loading: true });
|
|
3938
|
+
try {
|
|
3939
|
+
const response = await this.dataSource.fetchPlaylistCollection();
|
|
3940
|
+
this.store.setState({
|
|
3941
|
+
playlists: response.playlists,
|
|
3942
|
+
cursor: response.nextCursor,
|
|
3943
|
+
hasMore: response.hasMore,
|
|
3944
|
+
loading: false,
|
|
3945
|
+
error: null
|
|
3946
|
+
});
|
|
3947
|
+
this.updateCache();
|
|
3948
|
+
} catch (error) {
|
|
3949
|
+
this.store.setState({
|
|
3950
|
+
loading: false,
|
|
3951
|
+
error: error instanceof Error ? error : new Error("Failed to refresh playlist collection")
|
|
3952
|
+
});
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
/**
|
|
3956
|
+
* Reset the store to initial state
|
|
3957
|
+
*/
|
|
3958
|
+
reset() {
|
|
3959
|
+
this.store.setState(createInitialState7());
|
|
3960
|
+
}
|
|
3961
|
+
};
|
|
3962
|
+
/** Cache key for playlist collection */
|
|
3963
|
+
_PlaylistCollectionManager.CACHE_KEY = "sv-playlist-collection";
|
|
3964
|
+
var PlaylistCollectionManager = _PlaylistCollectionManager;
|
|
3965
|
+
|
|
3966
|
+
export { CommentManager, DEFAULT_COMMENT_MANAGER_CONFIG, DEFAULT_FEED_CONFIG, DEFAULT_LIFECYCLE_CONFIG, DEFAULT_OPTIMISTIC_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_PREFETCH_CACHE_CONFIG, DEFAULT_PREFETCH_CONFIG, DEFAULT_RESOURCE_CONFIG, FeedManager, LifecycleManager, OptimisticManager, PlayerEngine, PlayerStatus, PlaylistCollectionManager, PlaylistManager, ResourceGovernor, calculatePrefetchIndices, calculateWindowIndices, canPause, canPlay, canSeek, computeAllocationChanges, createInitialCommentState, createInitialVideoCommentState, isActiveState, isValidTransition, mapNetworkType };
|