@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +808 -618
  2. package/dist/index.js +855 -458
  3. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // ../../node_modules/.pnpm/zustand@5.0.9_@types+react@19.2.7_react@19.2.3/node_modules/zustand/esm/vanilla.mjs
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.mergeVideos(response.items, true);
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
- * Get a video by ID
230
- * Also updates LRU access time for garbage collection
231
- */
232
- getVideo(id) {
233
- const video = this.store.getState().itemsById.get(id);
234
- if (video) {
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 video;
292
+ return item;
238
293
  }
239
294
  /**
240
- * Get ordered list of videos
295
+ * Get ordered list of items
241
296
  */
242
- getVideos() {
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 a video in the feed (for optimistic updates)
302
+ * Update an item in the feed (for optimistic updates)
248
303
  */
249
- updateVideo(id, updates) {
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 - Video items from snapshot
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 video of items) {
311
- newItemsById.set(video.id, video);
312
- newDisplayOrder.push(video.id);
313
- this.accessOrder.set(video.id, now);
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 videos (tail of feed)
335
- * These are videos user hasn't seen yet, perfect for instant display
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 allVideos = this.getVideos();
342
- if (allVideos.length < this.prefetchConfig.maxVideos) {
509
+ const allItems = this.getItems();
510
+ if (allItems.length < this.prefetchConfig.maxItems) {
343
511
  return;
344
512
  }
345
- const tailVideos = allVideos.slice(-this.prefetchConfig.maxVideos);
513
+ const tailItems = allItems.slice(-this.prefetchConfig.maxItems);
346
514
  const cacheData = {
347
- items: tailVideos,
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 videos that user has scrolled past from prefetch cache
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 videos at or before current position
406
- * This ensures user doesn't rewatch videos on reload
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 video index in feed
605
+ * @param currentIndex - Current focused item index in feed
409
606
  */
410
- async evictViewedVideosFromCache(currentIndex) {
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 allVideos = this.getVideos();
418
- const currentVideo = allVideos[currentIndex];
419
- if (!currentVideo) return;
420
- const viewedVideoIds = new Set(allVideos.slice(0, currentIndex + 1).map((v) => v.id));
421
- const updatedItems = cache.items.filter((item) => !viewedVideoIds.has(item.id));
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
- this.addVideos(response.items);
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 videos with deduplication
678
+ * Add items with deduplication
478
679
  * Triggers garbage collection if cache exceeds maxCacheSize
479
680
  */
480
- addVideos(videos) {
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 video of videos) {
486
- if (!newItemsById.has(video.id)) {
487
- newItemsById.set(video.id, video);
488
- newDisplayOrder.push(video.id);
489
- this.accessOrder.set(video.id, now);
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 videos (for SWR revalidation)
502
- * Updates existing videos, adds new ones at the beginning
705
+ * Merge items (for SWR revalidation)
706
+ * Updates existing items, adds new ones at the beginning
503
707
  */
504
- mergeVideos(videos, prepend) {
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 video of videos) {
509
- if (newItemsById.has(video.id)) {
510
- newItemsById.set(video.id, video);
712
+ for (const item of items) {
713
+ if (newItemsById.has(item.id)) {
714
+ newItemsById.set(item.id, item);
511
715
  } else {
512
- newItemsById.set(video.id, video);
513
- newIds.push(video.id);
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 videos by last access time (oldest first)
587
- * 2. Evict oldest videos until cache is within limit
588
- * 3. Keep videos that are currently in viewport (most recent in displayOrder)
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 preloadPromises = indicesToPreload.map(async (index) => {
2364
- const videoInfo = this.videoSourceGetter?.(index);
2365
- if (!videoInfo) return;
2366
- try {
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] Preloading video at index ${index} (${videoInfo.id})`
2593
+ `[ResourceGovernor] Preload complete for index ${index} (${result.loadedBytes ?? 0} bytes)`
2369
2594
  );
2370
- const result = await this.videoLoader?.preload(videoInfo.id, videoInfo.source, {
2371
- priority: indicesToPreload.indexOf(index)
2372
- // Lower index = higher priority
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
- await Promise.allSettled(preloadPromises);
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 video */
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 a video with optimistic update
2688
+ * Like an item with optimistic update
2472
2689
  */
2473
- async like(videoId) {
2474
- return this.performAction("like", videoId, async () => {
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(videoId);
2693
+ await this.interaction.like(itemId);
2477
2694
  });
2478
2695
  }
2479
2696
  /**
2480
- * Unlike a video with optimistic update
2697
+ * Unlike an item with optimistic update
2481
2698
  */
2482
- async unlike(videoId) {
2483
- return this.performAction("unlike", videoId, async () => {
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(videoId);
2702
+ await this.interaction.unlike(itemId);
2486
2703
  });
2487
2704
  }
2488
2705
  /**
2489
- * Toggle like state with DEBOUNCE (like if not liked, unlike if liked)
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(videoId) {
2499
- const video = this.feedManager?.getVideo(videoId);
2500
- if (!video) {
2501
- this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
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 newIsLiked = !video.isLiked;
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
- this.feedManager?.updateVideo(videoId, {
2718
+ const currentLikesFromStore = item.stats.likes;
2719
+ this.feedManager?.updateItem(itemId, {
2507
2720
  isLiked: newIsLiked,
2508
- stats: { ...video.stats, likes: Math.max(0, video.stats.likes + likeDelta) }
2721
+ stats: { ...item.stats, likes: Math.max(0, currentLikesFromStore + likeDelta) }
2509
2722
  });
2510
- this.logger?.debug(`[OptimisticManager] UI updated: ${videoId} isLiked=${newIsLiked}`);
2511
- const existingTimer = this.likeDebounceTimers.get(videoId);
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(videoId);
2517
- this.executeDebouncedLikeApi(videoId);
2747
+ this.likeDebounceTimers.delete(itemId);
2748
+ this.executeDebouncedLikeApi(itemId, actionId);
2518
2749
  }, this.debounceDelay);
2519
- this.likeDebounceTimers.set(videoId, timer);
2750
+ this.likeDebounceTimers.set(itemId, timer);
2520
2751
  }
2521
2752
  /**
2522
- * Execute the actual API call after debounce
2523
- * Reads current state from FeedManager to get final intended state
2753
+ * Execute API call after debounce delay
2524
2754
  */
2525
- async executeDebouncedLikeApi(videoId) {
2526
- const video = this.feedManager?.getVideo(videoId);
2527
- if (!video) return;
2528
- const shouldLike = video.isLiked;
2529
- this.logger?.debug(`[OptimisticManager] Debounce fired: ${videoId} shouldLike=${shouldLike}`);
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
- throw new Error("No interaction adapter");
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(videoId);
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
- const err = error instanceof Error ? error : new Error(String(error));
2544
- this.logger?.warn(`[OptimisticManager] API failed: ${videoId}`, { error: err.message });
2545
- const currentVideo = this.feedManager?.getVideo(videoId);
2546
- if (currentVideo) {
2547
- const rollbackIsLiked = !shouldLike;
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?.updateVideo(videoId, {
2788
+ this.feedManager?.updateItem(itemId, {
2550
2789
  isLiked: rollbackIsLiked,
2551
2790
  stats: {
2552
- ...currentVideo.stats,
2553
- likes: Math.max(0, currentVideo.stats.likes + rollbackDelta)
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
- * @deprecated Use toggleLike() instead - it now includes debounce
2564
- * Legacy toggle that waits for API response
2799
+ * Remove a pending action by ID
2565
2800
  */
2566
- async toggleLikeSync(videoId) {
2567
- const video = this.feedManager?.getVideo(videoId);
2568
- if (!video) {
2569
- this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
2570
- return false;
2571
- }
2572
- return video.isLiked ? this.unlike(videoId) : this.like(videoId);
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
- * Follow a video author with optimistic update
2812
+ * Toggle follow state
2576
2813
  */
2577
- async follow(videoId) {
2578
- return this.performAction("follow", videoId, async () => {
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(videoId);
2822
+ await this.interaction.follow(itemId);
2581
2823
  });
2582
2824
  }
2583
- /**
2584
- * Unfollow a video author with optimistic update
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(videoId);
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
- * Toggle follow state
2839
+ * Reset all optimistic state
2594
2840
  */
2595
- async toggleFollow(videoId) {
2596
- const video = this.feedManager?.getVideo(videoId);
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
- // PUBLIC API - STATE MANAGEMENT
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 === videoId && action.status === "pending" && (!type || action.type === type)) {
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 failedQueue = [...state.failedQueue];
2640
- for (const actionId of failedQueue) {
2641
- const action = state.pendingActions.get(actionId);
2642
- if (action && action.retryCount < this.config.maxRetries) {
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 newPendingActions = new Map(state.pendingActions);
2654
- for (const actionId of state.failedQueue) {
2655
- const action = newPendingActions.get(actionId);
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
- newPendingActions.delete(actionId);
2886
+ newPending.delete(id);
2659
2887
  }
2660
2888
  }
2661
2889
  this.store.setState({
2662
- pendingActions: newPendingActions,
2890
+ pendingActions: newPending,
2663
2891
  failedQueue: [],
2664
- hasPending: newPendingActions.size > 0
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.cancelRetryTimer();
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 METHODS
2900
+ // PRIVATE UTILS
2703
2901
  // ═══════════════════════════════════════════════════════════════
2704
- /**
2705
- * Perform an optimistic action
2706
- */
2707
- async performAction(type, videoId, apiCall) {
2708
- if (this.hasPendingAction(videoId, type)) {
2709
- this.logger?.debug(`[OptimisticManager] Duplicate action skipped: ${type} ${videoId}`);
2710
- return false;
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
- const video = this.feedManager?.getVideo(videoId);
2713
- if (!video) {
2714
- this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
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, video),
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.emitEvent({ type: "actionStart", action });
2728
- this.applyOptimisticUpdate(type, video);
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.emitEvent({ type: "actionRollback", action: { ...action, status: "failed" } });
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
- * Create rollback data based on action type
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
- switch (type) {
2781
- case "like":
2782
- this.feedManager.updateVideo(video.id, {
2783
- isLiked: true,
2784
- stats: { ...video.stats, likes: video.stats.likes + 1 }
2785
- });
2786
- break;
2787
- case "unlike":
2788
- this.feedManager.updateVideo(video.id, {
2789
- isLiked: false,
2790
- stats: { ...video.stats, likes: Math.max(0, video.stats.likes - 1) }
2791
- });
2792
- break;
2793
- case "follow":
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
- if (!this.feedManager) return;
2810
- this.feedManager.updateVideo(action.videoId, action.rollbackData);
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
- const state = this.store.getState();
2817
- const newPendingActions = new Map(state.pendingActions);
2818
- newPendingActions.set(action.id, action);
2819
- this.store.setState({
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
- * Mark action as success
2826
- */
2827
- markActionSuccess(actionId) {
2828
- const state = this.store.getState();
2829
- const newPendingActions = new Map(state.pendingActions);
2830
- newPendingActions.delete(actionId);
2831
- const newFailedQueue = state.failedQueue.filter((id) => id !== actionId);
2832
- this.store.setState({
2833
- pendingActions: newPendingActions,
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
- * Mark action as failed
2840
- */
2841
- markActionFailed(actionId, error) {
2842
- const state = this.store.getState();
2843
- const action = state.pendingActions.get(actionId);
2844
- if (!action) return;
2845
- const newPendingActions = new Map(state.pendingActions);
2846
- newPendingActions.set(actionId, {
2847
- ...action,
2848
- status: "failed",
2849
- error
2850
- });
2851
- const newFailedQueue = state.failedQueue.includes(actionId) ? state.failedQueue : [...state.failedQueue, actionId];
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.emitEvent({ type: "retryStart", actionId: action.id });
2862
- const state = this.store.getState();
2863
- const newPendingActions = new Map(state.pendingActions);
2864
- const updatedAction = {
2865
- ...action,
2866
- retryCount: action.retryCount + 1,
2867
- status: "pending",
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 video = this.feedManager?.getVideo(action.videoId);
2877
- if (video) {
2878
- this.applyOptimisticUpdate(action.type, video);
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.executeApiCall(action.type, action.videoId);
2882
- this.markActionSuccess(action.id);
2883
- this.emitEvent({ type: "actionSuccess", action: { ...updatedAction, status: "success" } });
2884
- } catch (error) {
2885
- const err = error instanceof Error ? error : new Error(String(error));
2886
- if (updatedAction.retryCount >= this.config.maxRetries) {
2887
- this.emitEvent({ type: "retryExhausted", action: updatedAction });
2888
- this.markActionFailed(action.id, "Max retries exhausted");
2889
- } else {
2890
- this.markActionFailed(action.id, err.message);
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.applyRollback(action);
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 videoState = this.store.getState().byVideoId.get(videoId);
3373
- if (!videoState) return;
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 videoState = this.store.getState().byVideoId.get(videoId);
3397
- if (!videoState) return;
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
- const displayOrder = videoState.displayOrder.map(
3402
- (id) => id === optimisticId ? realComment.id : id
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
- 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, ResourceGovernor, calculatePrefetchIndices, calculateWindowIndices, canPause, canPlay, canSeek, computeAllocationChanges, createInitialCommentState, createInitialVideoCommentState, isActiveState, isValidTransition, mapNetworkType };
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 };