@xhub-short/core 0.1.0-beta.1 → 0.1.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,26 @@
1
- import { createStore } from 'zustand/vanilla';
2
-
3
- // src/feed/FeedManager.ts
1
+ // ../../node_modules/.pnpm/zustand@5.0.9_@types+react@19.2.7_react@19.2.3/node_modules/zustand/esm/vanilla.mjs
2
+ var createStoreImpl = (createState) => {
3
+ let state;
4
+ const listeners = /* @__PURE__ */ new Set();
5
+ const setState = (partial, replace) => {
6
+ const nextState = typeof partial === "function" ? partial(state) : partial;
7
+ if (!Object.is(nextState, state)) {
8
+ const previousState = state;
9
+ state = (replace != null ? replace : typeof nextState !== "object" || nextState === null) ? nextState : Object.assign({}, state, nextState);
10
+ listeners.forEach((listener) => listener(state, previousState));
11
+ }
12
+ };
13
+ const getState = () => state;
14
+ const getInitialState = () => initialState;
15
+ const subscribe = (listener) => {
16
+ listeners.add(listener);
17
+ return () => listeners.delete(listener);
18
+ };
19
+ const api = { setState, getState, getInitialState, subscribe };
20
+ const initialState = state = createState(setState, getState, api);
21
+ return api;
22
+ };
23
+ var createStore = ((createState) => createState ? createStoreImpl(createState) : createStoreImpl);
4
24
 
5
25
  // src/feed/types.ts
6
26
  var DEFAULT_FEED_CONFIG = {
@@ -13,6 +33,13 @@ var DEFAULT_FEED_CONFIG = {
13
33
  maxCacheSize: 100,
14
34
  enableGC: true
15
35
  };
36
+ var DEFAULT_PREFETCH_CACHE_CONFIG = {
37
+ enabled: true,
38
+ maxVideos: 10,
39
+ storageKey: "sv-prefetch-cache",
40
+ enableDynamicEviction: true,
41
+ evictionThrottleMs: 2e3
42
+ };
16
43
 
17
44
  // src/feed/FeedManager.ts
18
45
  var createInitialState = () => ({
@@ -26,8 +53,8 @@ var createInitialState = () => ({
26
53
  isStale: false,
27
54
  lastFetchTime: null
28
55
  });
29
- var FeedManager = class {
30
- constructor(dataSource, config = {}) {
56
+ var _FeedManager = class _FeedManager {
57
+ constructor(dataSource, config = {}, storage, prefetchConfig) {
31
58
  this.dataSource = dataSource;
32
59
  /** Abort controller for cancelling in-flight requests */
33
60
  this.abortController = null;
@@ -42,8 +69,46 @@ var FeedManager = class {
42
69
  */
43
70
  this.accessOrder = /* @__PURE__ */ new Map();
44
71
  this.config = { ...DEFAULT_FEED_CONFIG, ...config };
72
+ this.prefetchConfig = { ...DEFAULT_PREFETCH_CACHE_CONFIG, ...prefetchConfig };
73
+ this.storage = storage ?? null;
45
74
  this.store = createStore(createInitialState);
46
75
  }
76
+ /**
77
+ * Prefetch feed data and store in global memory cache
78
+ *
79
+ * @param dataSource - Data source to fetch from
80
+ * @param options - Prefetch options
81
+ */
82
+ static async prefetch(dataSource, options = {}) {
83
+ if (_FeedManager.globalMemoryCache) {
84
+ const now = Date.now();
85
+ const ttl = options.ttl ?? 5 * 60 * 1e3;
86
+ if (now - _FeedManager.globalMemoryCache.timestamp < ttl) {
87
+ return;
88
+ }
89
+ }
90
+ try {
91
+ const response = await dataSource.fetchFeed(void 0);
92
+ _FeedManager.globalMemoryCache = {
93
+ items: response.items,
94
+ nextCursor: response.nextCursor,
95
+ timestamp: Date.now()
96
+ };
97
+ } catch {
98
+ }
99
+ }
100
+ /**
101
+ * Check if prefetch cache exists and is valid
102
+ */
103
+ static hasPrefetchCache() {
104
+ return !!_FeedManager.globalMemoryCache;
105
+ }
106
+ /**
107
+ * Clear prefetch cache
108
+ */
109
+ static clearPrefetchCache() {
110
+ _FeedManager.globalMemoryCache = null;
111
+ }
47
112
  // ═══════════════════════════════════════════════════════════════
48
113
  // PUBLIC API
49
114
  // ═══════════════════════════════════════════════════════════════
@@ -60,6 +125,12 @@ var FeedManager = class {
60
125
  * - Prevents duplicate API calls from rapid UI interactions
61
126
  */
62
127
  async loadInitial() {
128
+ if (_FeedManager.globalMemoryCache) {
129
+ const { items, nextCursor } = _FeedManager.globalMemoryCache;
130
+ this.hydrateFromSnapshot(items, nextCursor, { markAsStale: false });
131
+ _FeedManager.globalMemoryCache = null;
132
+ return;
133
+ }
63
134
  const dedupeKey = "__initial__";
64
135
  const existingRequest = this.inFlightRequests.get(dedupeKey);
65
136
  if (existingRequest) {
@@ -84,6 +155,7 @@ var FeedManager = class {
84
155
  async executeLoadInitial() {
85
156
  try {
86
157
  await this.fetchWithRetry();
158
+ this.updatePrefetchCache();
87
159
  } catch (error) {
88
160
  this.handleError(error, "loadInitial");
89
161
  } finally {
@@ -124,6 +196,7 @@ var FeedManager = class {
124
196
  async executeLoadMore(cursor) {
125
197
  try {
126
198
  await this.fetchWithRetry(cursor);
199
+ this.updatePrefetchCache();
127
200
  } catch (error) {
128
201
  this.handleError(error, "loadMore");
129
202
  } finally {
@@ -182,6 +255,40 @@ var FeedManager = class {
182
255
  this.store.setState({ itemsById: newItemsById });
183
256
  }
184
257
  }
258
+ /**
259
+ * Remove an item from the feed
260
+ *
261
+ * Used for:
262
+ * - Report: Remove reported content from feed
263
+ * - Not Interested: Remove content user doesn't want to see
264
+ *
265
+ * @param id - Content ID to remove
266
+ * @returns true if item was removed, false if not found
267
+ *
268
+ * @example
269
+ * ```typescript
270
+ * // User reports a video
271
+ * const wasRemoved = feedManager.removeItem(videoId);
272
+ * if (wasRemoved) {
273
+ * // Navigate to next video
274
+ * }
275
+ * ```
276
+ */
277
+ removeItem(id) {
278
+ const state = this.store.getState();
279
+ if (!state.itemsById.has(id)) {
280
+ return false;
281
+ }
282
+ const newItemsById = new Map(state.itemsById);
283
+ newItemsById.delete(id);
284
+ const newDisplayOrder = state.displayOrder.filter((itemId) => itemId !== id);
285
+ this.accessOrder.delete(id);
286
+ this.store.setState({
287
+ itemsById: newItemsById,
288
+ displayOrder: newDisplayOrder
289
+ });
290
+ return true;
291
+ }
185
292
  /**
186
293
  * Check if data is stale and needs revalidation
187
294
  */
@@ -252,6 +359,121 @@ var FeedManager = class {
252
359
  });
253
360
  }
254
361
  // ═══════════════════════════════════════════════════════════════
362
+ // PREFETCH CACHE METHODS
363
+ // ═══════════════════════════════════════════════════════════════
364
+ /**
365
+ * Update prefetch cache with current feed tail
366
+ * Called automatically after loadInitial() and loadMore()
367
+ *
368
+ * Strategy: Cache the LAST N videos (tail of feed)
369
+ * These are videos user hasn't seen yet, perfect for instant display
370
+ */
371
+ updatePrefetchCache() {
372
+ if (!this.prefetchConfig.enabled) return;
373
+ if (!this.storage) return;
374
+ const state = this.store.getState();
375
+ const allVideos = this.getVideos();
376
+ if (allVideos.length < this.prefetchConfig.maxVideos) {
377
+ return;
378
+ }
379
+ const tailVideos = allVideos.slice(-this.prefetchConfig.maxVideos);
380
+ const cacheData = {
381
+ items: tailVideos,
382
+ savedAt: Date.now(),
383
+ cursor: state.cursor
384
+ };
385
+ this.savePrefetchCacheAsync(cacheData);
386
+ }
387
+ /**
388
+ * Save prefetch cache to storage (async, non-blocking)
389
+ */
390
+ async savePrefetchCacheAsync(data) {
391
+ if (!this.storage) return;
392
+ try {
393
+ await this.storage.set(this.prefetchConfig.storageKey, data);
394
+ } catch {
395
+ }
396
+ }
397
+ /**
398
+ * Load prefetch cache from storage
399
+ * Returns null if no cache, disabled, or storage error
400
+ */
401
+ async loadPrefetchCache() {
402
+ if (!this.prefetchConfig.enabled) return null;
403
+ if (!this.storage) return null;
404
+ try {
405
+ const data = await this.storage.get(this.prefetchConfig.storageKey);
406
+ if (!data || !data.items || data.items.length === 0) {
407
+ return null;
408
+ }
409
+ return data;
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+ /**
415
+ * Hydrate feed from prefetch cache for instant display
416
+ * Marks data as stale to trigger background revalidation
417
+ */
418
+ hydrateFromPrefetchCache(cache) {
419
+ this.hydrateFromSnapshot(cache.items, cache.cursor, {
420
+ markAsStale: true
421
+ // Trigger revalidation
422
+ });
423
+ }
424
+ /**
425
+ * Clear prefetch cache
426
+ * Call when user logs out or data should be invalidated
427
+ */
428
+ async clearPrefetchCache() {
429
+ if (!this.storage) return;
430
+ try {
431
+ await this.storage.remove(this.prefetchConfig.storageKey);
432
+ } catch {
433
+ }
434
+ }
435
+ /**
436
+ * Evict videos that user has scrolled past from prefetch cache
437
+ * Called when user's focusedIndex changes
438
+ *
439
+ * Strategy: Remove all videos at or before current position
440
+ * This ensures user doesn't rewatch videos on reload
441
+ *
442
+ * @param currentIndex - Current focused video index in feed
443
+ */
444
+ async evictViewedVideosFromCache(currentIndex) {
445
+ if (!this.prefetchConfig.enabled) return;
446
+ if (!this.prefetchConfig.enableDynamicEviction) return;
447
+ if (!this.storage) return;
448
+ try {
449
+ const cache = await this.loadPrefetchCache();
450
+ if (!cache || cache.items.length === 0) return;
451
+ const allVideos = this.getVideos();
452
+ const currentVideo = allVideos[currentIndex];
453
+ if (!currentVideo) return;
454
+ const viewedVideoIds = new Set(allVideos.slice(0, currentIndex + 1).map((v) => v.id));
455
+ const updatedItems = cache.items.filter((item) => !viewedVideoIds.has(item.id));
456
+ if (updatedItems.length === cache.items.length) return;
457
+ if (updatedItems.length === 0) {
458
+ await this.storage.remove(this.prefetchConfig.storageKey);
459
+ return;
460
+ }
461
+ const updatedCache = {
462
+ ...cache,
463
+ items: updatedItems,
464
+ savedAt: Date.now()
465
+ };
466
+ await this.storage.set(this.prefetchConfig.storageKey, updatedCache);
467
+ } catch {
468
+ }
469
+ }
470
+ /**
471
+ * Get prefetch cache configuration (for external access)
472
+ */
473
+ getPrefetchConfig() {
474
+ return this.prefetchConfig;
475
+ }
476
+ // ═══════════════════════════════════════════════════════════════
255
477
  // PRIVATE METHODS
256
478
  // ═══════════════════════════════════════════════════════════════
257
479
  /**
@@ -459,6 +681,12 @@ var FeedManager = class {
459
681
  };
460
682
  }
461
683
  };
684
+ // ═══════════════════════════════════════════════════════════════
685
+ // STATIC MEMORY CACHE (PREFETCH)
686
+ // ═══════════════════════════════════════════════════════════════
687
+ /** Static memory cache for explicit prefetching */
688
+ _FeedManager.globalMemoryCache = null;
689
+ var FeedManager = _FeedManager;
462
690
 
463
691
  // src/player/types.ts
464
692
  var PlayerStatus = /* @__PURE__ */ ((PlayerStatus2) => {
@@ -546,7 +774,8 @@ var createInitialState2 = (config) => ({
546
774
  ended: false,
547
775
  // Session restore
548
776
  pendingRestoreTime: null,
549
- pendingRestoreVideoId: null
777
+ pendingRestoreVideoId: null,
778
+ pendingRestoreFrame: null
550
779
  });
551
780
  var PlayerEngine = class {
552
781
  constructor(config = {}) {
@@ -595,7 +824,11 @@ var PlayerEngine = class {
595
824
  }
596
825
  return false;
597
826
  }
598
- const currentStatus = this.store.getState().status;
827
+ const currentState = this.store.getState();
828
+ const currentStatus = currentState.status;
829
+ if (currentState.currentVideo && currentState.currentVideo.id !== video.id) {
830
+ this.trackLeaveVideo(currentState);
831
+ }
599
832
  if (currentStatus !== "idle" /* IDLE */ && currentStatus !== "error" /* ERROR */ && currentStatus !== "paused" /* PAUSED */) {
600
833
  if (!this.transitionTo("idle" /* IDLE */)) {
601
834
  return false;
@@ -612,7 +845,9 @@ var PlayerEngine = class {
612
845
  loopCount: 0,
613
846
  watchTime: 0,
614
847
  error: null,
615
- ended: false
848
+ ended: false,
849
+ playbackRate: 1
850
+ // Reset speed to normal when loading new video
616
851
  });
617
852
  this.emitEvent({ type: "videoChange", video });
618
853
  this.logger?.debug(`[PlayerEngine] Loaded video: ${video.id}`);
@@ -821,22 +1056,24 @@ var PlayerEngine = class {
821
1056
  * ```typescript
822
1057
  * // During session restore
823
1058
  * if (result.playbackTime && result.currentVideoId) {
824
- * playerEngine.setRestorePosition(result.currentVideoId, result.playbackTime);
1059
+ * playerEngine.setRestorePosition(result.currentVideoId, result.playbackTime, result.restoreFrame);
825
1060
  * }
826
1061
  * ```
827
1062
  */
828
- setRestorePosition(videoId, time) {
1063
+ setRestorePosition(videoId, time, frame) {
829
1064
  this.store.setState({
830
1065
  pendingRestoreTime: time,
831
- pendingRestoreVideoId: videoId
1066
+ pendingRestoreVideoId: videoId,
1067
+ pendingRestoreFrame: frame ?? null
832
1068
  });
833
- this.logger?.debug("[PlayerEngine] Set restore position", { videoId, time });
1069
+ this.logger?.debug("[PlayerEngine] Set restore position", { videoId, time, hasFrame: !!frame });
834
1070
  }
835
1071
  /**
836
1072
  * Get and clear restore position for a video.
837
1073
  *
838
1074
  * VideoPlayer calls this when rendering to get startTime.
839
1075
  * Position is cleared after being read to prevent reuse.
1076
+ * Note: restoreFrame is NOT cleared here - use consumeRestoreFrame() separately.
840
1077
  *
841
1078
  * @param videoId - Video ID to check
842
1079
  * @returns Restore time in seconds, or null if no pending restore
@@ -846,14 +1083,51 @@ var PlayerEngine = class {
846
1083
  if (state.pendingRestoreVideoId === videoId && state.pendingRestoreTime !== null) {
847
1084
  const time = state.pendingRestoreTime;
848
1085
  this.store.setState({
849
- pendingRestoreTime: null,
850
- pendingRestoreVideoId: null
1086
+ pendingRestoreTime: null
1087
+ // Note: pendingRestoreVideoId and pendingRestoreFrame kept for frame consumption
851
1088
  });
852
1089
  this.logger?.debug("[PlayerEngine] Consumed restore position", { videoId, time });
853
1090
  return time;
854
1091
  }
855
1092
  return null;
856
1093
  }
1094
+ /**
1095
+ * Get and clear restore frame for a video.
1096
+ *
1097
+ * VideoSlot calls this to display instant preview while video loads.
1098
+ * Frame is cleared after being read to free memory.
1099
+ *
1100
+ * @param videoId - Video ID to check
1101
+ * @returns Base64 JPEG data URL, or null if no pending frame
1102
+ */
1103
+ consumeRestoreFrame(videoId) {
1104
+ const state = this.store.getState();
1105
+ if (state.pendingRestoreVideoId === videoId && state.pendingRestoreFrame !== null) {
1106
+ const frame = state.pendingRestoreFrame;
1107
+ this.store.setState({
1108
+ pendingRestoreFrame: null,
1109
+ pendingRestoreVideoId: null
1110
+ // Clear video ID since we've consumed both time and frame
1111
+ });
1112
+ this.logger?.debug("[PlayerEngine] Consumed restore frame", { videoId });
1113
+ return frame;
1114
+ }
1115
+ return null;
1116
+ }
1117
+ /**
1118
+ * Get restore frame without consuming (peek).
1119
+ * Used to display preview while keeping it available.
1120
+ *
1121
+ * @param videoId - Video ID to check
1122
+ * @returns Base64 JPEG data URL, or null if no pending frame
1123
+ */
1124
+ peekRestoreFrame(videoId) {
1125
+ const state = this.store.getState();
1126
+ if (state.pendingRestoreVideoId === videoId && state.pendingRestoreFrame !== null) {
1127
+ return state.pendingRestoreFrame;
1128
+ }
1129
+ return null;
1130
+ }
857
1131
  /**
858
1132
  * Check if there's a pending restore position for a video.
859
1133
  *
@@ -964,13 +1238,23 @@ var PlayerEngine = class {
964
1238
  }
965
1239
  /**
966
1240
  * Start watch time tracking
1241
+ * Increments watchTime every second and sends analytics heartbeat
967
1242
  */
968
1243
  startWatchTimeTracking() {
969
1244
  if (this.watchTimeInterval) return;
970
1245
  this.watchTimeInterval = setInterval(() => {
971
1246
  const state = this.store.getState();
972
- if (state.status === "playing" /* PLAYING */) {
973
- this.store.setState({ watchTime: state.watchTime + 1 });
1247
+ if (state.status === "playing" /* PLAYING */ && state.currentVideo) {
1248
+ const newWatchTime = state.watchTime + 1;
1249
+ this.store.setState({ watchTime: newWatchTime });
1250
+ if (this.analytics && newWatchTime >= this.config.watchTimeThreshold) {
1251
+ this.analytics.trackViewDuration(
1252
+ state.currentVideo.id,
1253
+ state.currentTime,
1254
+ // Use playback position (currentTime) for API
1255
+ state.duration
1256
+ );
1257
+ }
974
1258
  }
975
1259
  }, 1e3);
976
1260
  }
@@ -984,7 +1268,7 @@ var PlayerEngine = class {
984
1268
  }
985
1269
  }
986
1270
  /**
987
- * Track completion analytics
1271
+ * Track completion analytics (when video loops/ends)
988
1272
  */
989
1273
  trackCompletion(state, loopCount) {
990
1274
  if (!this.analytics || !state.currentVideo) return;
@@ -996,6 +1280,27 @@ var PlayerEngine = class {
996
1280
  this.analytics.trackCompletion(state.currentVideo.id, state.watchTime, loopCount);
997
1281
  }
998
1282
  }
1283
+ /**
1284
+ * Track when user leaves current video (scrolls to next video)
1285
+ * Sends final analytics event before video change
1286
+ */
1287
+ trackLeaveVideo(state) {
1288
+ if (!this.analytics || !state.currentVideo) return;
1289
+ if (state.watchTime >= this.config.watchTimeThreshold) {
1290
+ this.analytics.trackViewDuration(
1291
+ state.currentVideo.id,
1292
+ state.currentTime,
1293
+ // Use currentTime (playback position) instead of watchTime
1294
+ state.duration
1295
+ );
1296
+ this.logger?.debug("[PlayerEngine] Tracked leave event", {
1297
+ videoId: state.currentVideo.id,
1298
+ watchTime: state.watchTime,
1299
+ currentTime: state.currentTime
1300
+ });
1301
+ }
1302
+ this.stopWatchTimeTracking();
1303
+ }
999
1304
  /**
1000
1305
  * Categorize media error
1001
1306
  */
@@ -1331,6 +1636,9 @@ var LifecycleManager = class {
1331
1636
  if (validSnapshot.currentVideoId !== void 0) {
1332
1637
  result.currentVideoId = validSnapshot.currentVideoId;
1333
1638
  }
1639
+ if (validSnapshot.restoreFrame !== void 0) {
1640
+ result.restoreFrame = validSnapshot.restoreFrame;
1641
+ }
1334
1642
  }
1335
1643
  this.store.setState({
1336
1644
  isRestoring: false,
@@ -1341,7 +1649,8 @@ var LifecycleManager = class {
1341
1649
  this.logger?.debug("[LifecycleManager] Session restored", {
1342
1650
  itemCount: validSnapshot.items.length,
1343
1651
  needsRevalidation,
1344
- hasPlaybackTime: this.config.restorePlaybackPosition && validSnapshot.playbackTime !== void 0
1652
+ hasPlaybackTime: this.config.restorePlaybackPosition && validSnapshot.playbackTime !== void 0,
1653
+ hasRestoreFrame: this.config.restorePlaybackPosition && validSnapshot.restoreFrame !== void 0
1345
1654
  });
1346
1655
  return result;
1347
1656
  } catch (error) {
@@ -1366,6 +1675,7 @@ var LifecycleManager = class {
1366
1675
  * @param data - Snapshot data to save
1367
1676
  * @param data.playbackTime - Current video playback position (only saved if restorePlaybackPosition config is enabled)
1368
1677
  * @param data.currentVideoId - Current video ID (only saved if restorePlaybackPosition config is enabled)
1678
+ * @param data.restoreFrame - Captured video frame at playback position (only saved if restorePlaybackPosition is enabled)
1369
1679
  */
1370
1680
  async saveSnapshot(data) {
1371
1681
  if (!this.storage) {
@@ -1394,6 +1704,9 @@ var LifecycleManager = class {
1394
1704
  if (data.currentVideoId !== void 0) {
1395
1705
  snapshot.currentVideoId = data.currentVideoId;
1396
1706
  }
1707
+ if (data.restoreFrame !== void 0) {
1708
+ snapshot.restoreFrame = data.restoreFrame;
1709
+ }
1397
1710
  }
1398
1711
  await this.storage.saveSnapshot(snapshot);
1399
1712
  const timestamp = Date.now();
@@ -1404,7 +1717,8 @@ var LifecycleManager = class {
1404
1717
  this.emitEvent({ type: "saveComplete", timestamp });
1405
1718
  this.logger?.debug("[LifecycleManager] Snapshot saved", {
1406
1719
  itemCount: data.items.length,
1407
- hasPlaybackTime: this.config.restorePlaybackPosition && data.playbackTime !== void 0
1720
+ hasPlaybackTime: this.config.restorePlaybackPosition && data.playbackTime !== void 0,
1721
+ hasRestoreFrame: this.config.restorePlaybackPosition && data.restoreFrame !== void 0
1408
1722
  });
1409
1723
  return true;
1410
1724
  } catch (error) {
@@ -2670,4 +2984,627 @@ var OptimisticManager = class {
2670
2984
  }
2671
2985
  };
2672
2986
 
2673
- export { DEFAULT_FEED_CONFIG, DEFAULT_LIFECYCLE_CONFIG, DEFAULT_OPTIMISTIC_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_PREFETCH_CONFIG, DEFAULT_RESOURCE_CONFIG, FeedManager, LifecycleManager, OptimisticManager, PlayerEngine, PlayerStatus, ResourceGovernor, calculatePrefetchIndices, calculateWindowIndices, canPause, canPlay, canSeek, computeAllocationChanges, isActiveState, isValidTransition, mapNetworkType };
2987
+ // src/comment/types.ts
2988
+ var DEFAULT_COMMENT_MANAGER_CONFIG = {
2989
+ pageSize: 20,
2990
+ repliesPageSize: 10,
2991
+ cacheTTL: 5 * 60 * 1e3,
2992
+ // 5 minutes
2993
+ maxRetries: 2,
2994
+ repliesAutoExpandThreshold: 1,
2995
+ repliesCollapseLimit: 3,
2996
+ textMaxLines: 3,
2997
+ enableOptimistic: true
2998
+ };
2999
+ var createInitialVideoCommentState = () => ({
3000
+ commentsById: /* @__PURE__ */ new Map(),
3001
+ displayOrder: [],
3002
+ totalCount: 0,
3003
+ cursor: null,
3004
+ hasMore: true,
3005
+ loading: false,
3006
+ loadingMore: false,
3007
+ error: null,
3008
+ cachedAt: 0,
3009
+ isStale: true
3010
+ });
3011
+ var createInitialCommentState = () => ({
3012
+ byVideoId: /* @__PURE__ */ new Map(),
3013
+ activeVideoId: null,
3014
+ isPosting: false,
3015
+ postError: null,
3016
+ deletingId: null
3017
+ });
3018
+
3019
+ // src/comment/CommentManager.ts
3020
+ var CommentManager = class {
3021
+ constructor(adapter, config = {}, logger) {
3022
+ this.adapter = adapter;
3023
+ /** Request deduplication: Map of key → in-flight Promise */
3024
+ this.inFlightRequests = /* @__PURE__ */ new Map();
3025
+ /** Optimistic ID counter */
3026
+ this.optimisticIdCounter = 0;
3027
+ this.config = { ...DEFAULT_COMMENT_MANAGER_CONFIG, ...config };
3028
+ this.store = createStore(createInitialCommentState);
3029
+ this.logger = logger;
3030
+ }
3031
+ // ═══════════════════════════════════════════════════════════════
3032
+ // PUBLIC API - READ OPERATIONS
3033
+ // ═══════════════════════════════════════════════════════════════
3034
+ /**
3035
+ * Load comments for a video
3036
+ *
3037
+ * Features:
3038
+ * - Cache check with TTL
3039
+ * - Request deduplication
3040
+ * - Stale data detection
3041
+ */
3042
+ async loadComments(videoId, forceRefresh = false) {
3043
+ const dedupeKey = `loadComments:${videoId}`;
3044
+ const existingRequest = this.inFlightRequests.get(dedupeKey);
3045
+ if (existingRequest) {
3046
+ this.logger?.debug(`[CommentManager] Deduped loadComments: ${videoId}`);
3047
+ return existingRequest;
3048
+ }
3049
+ const state = this.store.getState();
3050
+ const videoState = state.byVideoId.get(videoId);
3051
+ if (!forceRefresh && videoState && !this.isCacheStale(videoState)) {
3052
+ this.logger?.debug(`[CommentManager] Using cached comments: ${videoId}`);
3053
+ return;
3054
+ }
3055
+ const request = this.executeLoadComments(videoId, forceRefresh);
3056
+ this.inFlightRequests.set(dedupeKey, request);
3057
+ try {
3058
+ await request;
3059
+ } finally {
3060
+ this.inFlightRequests.delete(dedupeKey);
3061
+ }
3062
+ }
3063
+ /**
3064
+ * Load more comments (pagination)
3065
+ */
3066
+ async loadMore(videoId) {
3067
+ const state = this.store.getState();
3068
+ const videoState = state.byVideoId.get(videoId);
3069
+ if (!videoState || videoState.loading || videoState.loadingMore || !videoState.hasMore) {
3070
+ return;
3071
+ }
3072
+ const dedupeKey = `loadMore:${videoId}:${videoState.cursor}`;
3073
+ const existingRequest = this.inFlightRequests.get(dedupeKey);
3074
+ if (existingRequest) {
3075
+ return existingRequest;
3076
+ }
3077
+ const request = this.executeLoadMore(videoId);
3078
+ this.inFlightRequests.set(dedupeKey, request);
3079
+ try {
3080
+ await request;
3081
+ } finally {
3082
+ this.inFlightRequests.delete(dedupeKey);
3083
+ }
3084
+ }
3085
+ /**
3086
+ * Load replies for a comment
3087
+ */
3088
+ async loadReplies(commentId) {
3089
+ const dedupeKey = `loadReplies:${commentId}`;
3090
+ const existingRequest = this.inFlightRequests.get(dedupeKey);
3091
+ if (existingRequest) {
3092
+ return existingRequest;
3093
+ }
3094
+ const request = this.executeLoadReplies(commentId);
3095
+ this.inFlightRequests.set(dedupeKey, request);
3096
+ try {
3097
+ await request;
3098
+ } finally {
3099
+ this.inFlightRequests.delete(dedupeKey);
3100
+ }
3101
+ }
3102
+ // ═══════════════════════════════════════════════════════════════
3103
+ // PUBLIC API - WRITE OPERATIONS
3104
+ // ═══════════════════════════════════════════════════════════════
3105
+ /**
3106
+ * Post a new comment with optimistic UI
3107
+ */
3108
+ async postComment(videoId, content, currentUser) {
3109
+ if (!content.trim()) return null;
3110
+ const optimisticId = this.generateOptimisticId();
3111
+ const optimisticComment = {
3112
+ id: optimisticId,
3113
+ videoId,
3114
+ content: content.trim(),
3115
+ author: currentUser,
3116
+ likeCount: 0,
3117
+ isLiked: false,
3118
+ replyCount: 0,
3119
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3120
+ isOwner: true,
3121
+ isPending: true
3122
+ };
3123
+ if (this.config.enableOptimistic) {
3124
+ this.addOptimisticComment(videoId, optimisticComment);
3125
+ }
3126
+ this.store.setState({ isPosting: true, postError: null });
3127
+ try {
3128
+ const realComment = await this.adapter.postComment({ videoId, content: content.trim() });
3129
+ this.replaceOptimisticComment(videoId, optimisticId, realComment);
3130
+ this.logger?.debug(`[CommentManager] Posted comment: ${realComment.id}`);
3131
+ return realComment;
3132
+ } catch (error) {
3133
+ if (this.config.enableOptimistic) {
3134
+ this.removeOptimisticComment(videoId, optimisticId);
3135
+ }
3136
+ const commentError = this.createError(error);
3137
+ this.store.setState({ postError: commentError });
3138
+ this.logger?.warn("[CommentManager] Post failed", { error: commentError.message });
3139
+ return null;
3140
+ } finally {
3141
+ this.store.setState({ isPosting: false });
3142
+ }
3143
+ }
3144
+ /**
3145
+ * Post a reply with optimistic UI
3146
+ */
3147
+ async postReply(videoId, parentId, content, currentUser, replyTo) {
3148
+ if (!content.trim()) return null;
3149
+ const optimisticId = this.generateOptimisticId();
3150
+ const optimisticReply = {
3151
+ id: optimisticId,
3152
+ parentId,
3153
+ content: content.trim(),
3154
+ author: currentUser,
3155
+ likeCount: 0,
3156
+ isLiked: false,
3157
+ replyTo,
3158
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3159
+ isOwner: true,
3160
+ isPending: true
3161
+ };
3162
+ if (this.config.enableOptimistic) {
3163
+ this.addOptimisticReply(videoId, parentId, optimisticReply);
3164
+ }
3165
+ this.store.setState({ isPosting: true, postError: null });
3166
+ try {
3167
+ const realReply = await this.adapter.postReply({
3168
+ videoId,
3169
+ parentId,
3170
+ content: content.trim(),
3171
+ replyToUserId: replyTo?.id
3172
+ });
3173
+ this.replaceOptimisticReply(videoId, parentId, optimisticId, realReply);
3174
+ this.logger?.debug(`[CommentManager] Posted reply: ${realReply.id}`);
3175
+ return realReply;
3176
+ } catch (error) {
3177
+ if (this.config.enableOptimistic) {
3178
+ this.removeOptimisticReply(videoId, parentId, optimisticId);
3179
+ }
3180
+ const commentError = this.createError(error);
3181
+ this.store.setState({ postError: commentError });
3182
+ this.logger?.warn("[CommentManager] Reply failed", { error: commentError.message });
3183
+ return null;
3184
+ } finally {
3185
+ this.store.setState({ isPosting: false });
3186
+ }
3187
+ }
3188
+ /**
3189
+ * Delete a comment with optimistic UI
3190
+ */
3191
+ async deleteComment(videoId, commentId, isReply = false, parentId) {
3192
+ const backup = isReply ? this.getReply(videoId, parentId, commentId) : this.getComment(videoId, commentId);
3193
+ if (!backup) return false;
3194
+ if (this.config.enableOptimistic) {
3195
+ this.store.setState({ deletingId: commentId });
3196
+ if (isReply && parentId) {
3197
+ this.removeReplyFromStore(videoId, parentId, commentId);
3198
+ } else {
3199
+ this.removeCommentFromStore(videoId, commentId);
3200
+ }
3201
+ }
3202
+ try {
3203
+ await this.adapter.deleteComment({
3204
+ videoId,
3205
+ commentId,
3206
+ isReply,
3207
+ parentId
3208
+ });
3209
+ this.logger?.debug(`[CommentManager] Deleted ${isReply ? "reply" : "comment"}: ${commentId}`);
3210
+ return true;
3211
+ } catch (error) {
3212
+ if (this.config.enableOptimistic) {
3213
+ if (isReply && parentId) {
3214
+ this.restoreReply(videoId, parentId, backup);
3215
+ } else {
3216
+ this.restoreComment(videoId, backup);
3217
+ }
3218
+ }
3219
+ this.logger?.warn(`[CommentManager] Delete failed: ${commentId}`, {
3220
+ error: error.message
3221
+ });
3222
+ return false;
3223
+ } finally {
3224
+ this.store.setState({ deletingId: null });
3225
+ }
3226
+ }
3227
+ /**
3228
+ * Like a comment/reply with optimistic UI
3229
+ */
3230
+ async likeComment(videoId, commentId, isReply = false, parentId) {
3231
+ this.updateLikeState(videoId, commentId, true, isReply, parentId);
3232
+ try {
3233
+ await this.adapter.likeComment(commentId);
3234
+ } catch (error) {
3235
+ this.updateLikeState(videoId, commentId, false, isReply, parentId);
3236
+ this.logger?.warn(`[CommentManager] Like failed: ${commentId}`, {
3237
+ error: error.message
3238
+ });
3239
+ }
3240
+ }
3241
+ /**
3242
+ * Unlike a comment/reply with optimistic UI
3243
+ */
3244
+ async unlikeComment(videoId, commentId, isReply = false, parentId) {
3245
+ this.updateLikeState(videoId, commentId, false, isReply, parentId);
3246
+ try {
3247
+ await this.adapter.unlikeComment(commentId);
3248
+ } catch (error) {
3249
+ this.updateLikeState(videoId, commentId, true, isReply, parentId);
3250
+ this.logger?.warn(`[CommentManager] Unlike failed: ${commentId}`, {
3251
+ error: error.message
3252
+ });
3253
+ }
3254
+ }
3255
+ // ═══════════════════════════════════════════════════════════════
3256
+ // PUBLIC API - STATE ACCESSORS
3257
+ // ═══════════════════════════════════════════════════════════════
3258
+ /**
3259
+ * Get comments for a video
3260
+ */
3261
+ getComments(videoId) {
3262
+ const state = this.store.getState();
3263
+ const videoState = state.byVideoId.get(videoId);
3264
+ if (!videoState) return [];
3265
+ return videoState.displayOrder.map((id) => videoState.commentsById.get(id)).filter((c) => c !== void 0);
3266
+ }
3267
+ /**
3268
+ * Get a single comment
3269
+ */
3270
+ getComment(videoId, commentId) {
3271
+ const state = this.store.getState();
3272
+ return state.byVideoId.get(videoId)?.commentsById.get(commentId);
3273
+ }
3274
+ /**
3275
+ * Get a single reply
3276
+ */
3277
+ getReply(videoId, parentId, replyId) {
3278
+ const comment = this.getComment(videoId, parentId);
3279
+ return comment?.replies?.find((r) => r.id === replyId);
3280
+ }
3281
+ /**
3282
+ * Get video comment state
3283
+ */
3284
+ getVideoState(videoId) {
3285
+ return this.store.getState().byVideoId.get(videoId);
3286
+ }
3287
+ /**
3288
+ * Set active video ID (for comment sheet)
3289
+ */
3290
+ setActiveVideo(videoId) {
3291
+ this.store.setState({ activeVideoId: videoId });
3292
+ }
3293
+ /**
3294
+ * Get config
3295
+ */
3296
+ getConfig() {
3297
+ return this.config;
3298
+ }
3299
+ /**
3300
+ * Clear comments for a video
3301
+ */
3302
+ clearVideoComments(videoId) {
3303
+ const state = this.store.getState();
3304
+ const newByVideoId = new Map(state.byVideoId);
3305
+ newByVideoId.delete(videoId);
3306
+ this.store.setState({ byVideoId: newByVideoId });
3307
+ }
3308
+ /**
3309
+ * Clear all comments
3310
+ */
3311
+ clearAll() {
3312
+ this.store.setState(createInitialCommentState());
3313
+ }
3314
+ // ═══════════════════════════════════════════════════════════════
3315
+ // PRIVATE - EXECUTE METHODS
3316
+ // ═══════════════════════════════════════════════════════════════
3317
+ async executeLoadComments(videoId, _forceRefresh) {
3318
+ this.updateVideoState(videoId, { loading: true, error: null });
3319
+ try {
3320
+ const response = await this.adapter.getComments(videoId, null, this.config.pageSize);
3321
+ const commentsById = /* @__PURE__ */ new Map();
3322
+ const displayOrder = [];
3323
+ for (const comment of response.items) {
3324
+ commentsById.set(comment.id, comment);
3325
+ displayOrder.push(comment.id);
3326
+ }
3327
+ this.updateVideoState(videoId, {
3328
+ commentsById,
3329
+ displayOrder,
3330
+ totalCount: response.totalCount,
3331
+ cursor: response.nextCursor,
3332
+ hasMore: response.hasMore,
3333
+ loading: false,
3334
+ cachedAt: Date.now(),
3335
+ isStale: false
3336
+ });
3337
+ this.logger?.debug(
3338
+ `[CommentManager] Loaded ${response.items.length} comments for ${videoId}`
3339
+ );
3340
+ } catch (error) {
3341
+ const commentError = this.createError(error);
3342
+ this.updateVideoState(videoId, { error: commentError, loading: false });
3343
+ throw error;
3344
+ }
3345
+ }
3346
+ async executeLoadMore(videoId) {
3347
+ const videoState = this.store.getState().byVideoId.get(videoId);
3348
+ if (!videoState) return;
3349
+ this.updateVideoState(videoId, { loadingMore: true });
3350
+ try {
3351
+ const response = await this.adapter.getComments(
3352
+ videoId,
3353
+ videoState.cursor,
3354
+ this.config.pageSize
3355
+ );
3356
+ const commentsById = new Map(videoState.commentsById);
3357
+ const displayOrder = [...videoState.displayOrder];
3358
+ for (const comment of response.items) {
3359
+ if (!commentsById.has(comment.id)) {
3360
+ commentsById.set(comment.id, comment);
3361
+ displayOrder.push(comment.id);
3362
+ }
3363
+ }
3364
+ this.updateVideoState(videoId, {
3365
+ commentsById,
3366
+ displayOrder,
3367
+ cursor: response.nextCursor,
3368
+ hasMore: response.hasMore,
3369
+ loadingMore: false
3370
+ });
3371
+ this.logger?.debug(`[CommentManager] Loaded ${response.items.length} more comments`);
3372
+ } catch (error) {
3373
+ this.updateVideoState(videoId, { loadingMore: false });
3374
+ throw error;
3375
+ }
3376
+ }
3377
+ async executeLoadReplies(commentId) {
3378
+ try {
3379
+ const response = await this.adapter.getReplies(commentId, null, this.config.repliesPageSize);
3380
+ const state = this.store.getState();
3381
+ for (const [videoId, videoState] of state.byVideoId) {
3382
+ const comment = videoState.commentsById.get(commentId);
3383
+ if (comment) {
3384
+ const updatedComment = {
3385
+ ...comment,
3386
+ replies: response.items,
3387
+ repliesCursor: response.nextCursor,
3388
+ repliesLoaded: !response.hasMore
3389
+ };
3390
+ const commentsById = new Map(videoState.commentsById);
3391
+ commentsById.set(commentId, updatedComment);
3392
+ this.updateVideoState(videoId, { commentsById });
3393
+ this.logger?.debug(
3394
+ `[CommentManager] Loaded ${response.items.length} replies for ${commentId}`
3395
+ );
3396
+ break;
3397
+ }
3398
+ }
3399
+ } catch (error) {
3400
+ this.logger?.warn(`[CommentManager] Load replies failed: ${commentId}`);
3401
+ throw error;
3402
+ }
3403
+ }
3404
+ // ═══════════════════════════════════════════════════════════════
3405
+ // PRIVATE - OPTIMISTIC UPDATE HELPERS
3406
+ // ═══════════════════════════════════════════════════════════════
3407
+ addOptimisticComment(videoId, comment) {
3408
+ const state = this.store.getState();
3409
+ const videoState = state.byVideoId.get(videoId) ?? createInitialVideoCommentState();
3410
+ const commentsById = new Map(videoState.commentsById);
3411
+ commentsById.set(comment.id, comment);
3412
+ const displayOrder = [comment.id, ...videoState.displayOrder];
3413
+ this.updateVideoState(videoId, {
3414
+ commentsById,
3415
+ displayOrder,
3416
+ totalCount: videoState.totalCount + 1
3417
+ });
3418
+ }
3419
+ removeOptimisticComment(videoId, optimisticId) {
3420
+ const videoState = this.store.getState().byVideoId.get(videoId);
3421
+ if (!videoState) return;
3422
+ const commentsById = new Map(videoState.commentsById);
3423
+ commentsById.delete(optimisticId);
3424
+ const displayOrder = videoState.displayOrder.filter((id) => id !== optimisticId);
3425
+ this.updateVideoState(videoId, {
3426
+ commentsById,
3427
+ displayOrder,
3428
+ totalCount: Math.max(0, videoState.totalCount - 1)
3429
+ });
3430
+ }
3431
+ replaceOptimisticComment(videoId, optimisticId, realComment) {
3432
+ const state = this.store.getState();
3433
+ const videoState = state.byVideoId.get(videoId) ?? createInitialVideoCommentState();
3434
+ const commentsById = new Map(videoState.commentsById);
3435
+ commentsById.delete(optimisticId);
3436
+ commentsById.set(realComment.id, realComment);
3437
+ let displayOrder;
3438
+ if (videoState.displayOrder.includes(optimisticId)) {
3439
+ displayOrder = videoState.displayOrder.map(
3440
+ (id) => id === optimisticId ? realComment.id : id
3441
+ );
3442
+ } else {
3443
+ displayOrder = [realComment.id, ...videoState.displayOrder];
3444
+ }
3445
+ this.updateVideoState(videoId, { commentsById, displayOrder });
3446
+ }
3447
+ addOptimisticReply(videoId, parentId, reply) {
3448
+ const videoState = this.store.getState().byVideoId.get(videoId);
3449
+ if (!videoState) return;
3450
+ const comment = videoState.commentsById.get(parentId);
3451
+ if (!comment) return;
3452
+ const updatedComment = {
3453
+ ...comment,
3454
+ replies: [...comment.replies ?? [], reply],
3455
+ replyCount: comment.replyCount + 1
3456
+ };
3457
+ const commentsById = new Map(videoState.commentsById);
3458
+ commentsById.set(parentId, updatedComment);
3459
+ this.updateVideoState(videoId, { commentsById });
3460
+ }
3461
+ removeOptimisticReply(videoId, parentId, optimisticId) {
3462
+ const videoState = this.store.getState().byVideoId.get(videoId);
3463
+ if (!videoState) return;
3464
+ const comment = videoState.commentsById.get(parentId);
3465
+ if (!comment) return;
3466
+ const updatedComment = {
3467
+ ...comment,
3468
+ replies: comment.replies?.filter((r) => r.id !== optimisticId),
3469
+ replyCount: Math.max(0, comment.replyCount - 1)
3470
+ };
3471
+ const commentsById = new Map(videoState.commentsById);
3472
+ commentsById.set(parentId, updatedComment);
3473
+ this.updateVideoState(videoId, { commentsById });
3474
+ }
3475
+ replaceOptimisticReply(videoId, parentId, optimisticId, realReply) {
3476
+ const videoState = this.store.getState().byVideoId.get(videoId);
3477
+ if (!videoState) return;
3478
+ const comment = videoState.commentsById.get(parentId);
3479
+ if (!comment) return;
3480
+ const updatedComment = {
3481
+ ...comment,
3482
+ replies: comment.replies?.map((r) => r.id === optimisticId ? realReply : r)
3483
+ };
3484
+ const commentsById = new Map(videoState.commentsById);
3485
+ commentsById.set(parentId, updatedComment);
3486
+ this.updateVideoState(videoId, { commentsById });
3487
+ }
3488
+ removeCommentFromStore(videoId, commentId) {
3489
+ const videoState = this.store.getState().byVideoId.get(videoId);
3490
+ if (!videoState) return;
3491
+ const commentsById = new Map(videoState.commentsById);
3492
+ commentsById.delete(commentId);
3493
+ const displayOrder = videoState.displayOrder.filter((id) => id !== commentId);
3494
+ this.updateVideoState(videoId, {
3495
+ commentsById,
3496
+ displayOrder,
3497
+ totalCount: Math.max(0, videoState.totalCount - 1)
3498
+ });
3499
+ }
3500
+ removeReplyFromStore(videoId, parentId, replyId) {
3501
+ const videoState = this.store.getState().byVideoId.get(videoId);
3502
+ if (!videoState) return;
3503
+ const comment = videoState.commentsById.get(parentId);
3504
+ if (!comment) return;
3505
+ const updatedComment = {
3506
+ ...comment,
3507
+ replies: comment.replies?.filter((r) => r.id !== replyId),
3508
+ replyCount: Math.max(0, comment.replyCount - 1)
3509
+ };
3510
+ const commentsById = new Map(videoState.commentsById);
3511
+ commentsById.set(parentId, updatedComment);
3512
+ this.updateVideoState(videoId, { commentsById });
3513
+ }
3514
+ restoreComment(videoId, comment) {
3515
+ const videoState = this.store.getState().byVideoId.get(videoId);
3516
+ if (!videoState) return;
3517
+ const commentsById = new Map(videoState.commentsById);
3518
+ commentsById.set(comment.id, comment);
3519
+ const displayOrder = videoState.displayOrder.includes(comment.id) ? videoState.displayOrder : [comment.id, ...videoState.displayOrder];
3520
+ this.updateVideoState(videoId, {
3521
+ commentsById,
3522
+ displayOrder,
3523
+ totalCount: videoState.totalCount + 1
3524
+ });
3525
+ }
3526
+ restoreReply(videoId, parentId, reply) {
3527
+ const videoState = this.store.getState().byVideoId.get(videoId);
3528
+ if (!videoState) return;
3529
+ const comment = videoState.commentsById.get(parentId);
3530
+ if (!comment) return;
3531
+ const updatedComment = {
3532
+ ...comment,
3533
+ replies: [...comment.replies ?? [], reply],
3534
+ replyCount: comment.replyCount + 1
3535
+ };
3536
+ const commentsById = new Map(videoState.commentsById);
3537
+ commentsById.set(parentId, updatedComment);
3538
+ this.updateVideoState(videoId, { commentsById });
3539
+ }
3540
+ updateLikeState(videoId, commentId, isLiked, isReply, parentId) {
3541
+ const videoState = this.store.getState().byVideoId.get(videoId);
3542
+ if (!videoState) return;
3543
+ if (isReply && parentId) {
3544
+ const comment = videoState.commentsById.get(parentId);
3545
+ if (!comment) return;
3546
+ const updatedReplies = comment.replies?.map(
3547
+ (r) => r.id === commentId ? { ...r, isLiked, likeCount: r.likeCount + (isLiked ? 1 : -1) } : r
3548
+ );
3549
+ const commentsById = new Map(videoState.commentsById);
3550
+ commentsById.set(parentId, { ...comment, replies: updatedReplies });
3551
+ this.updateVideoState(videoId, { commentsById });
3552
+ } else {
3553
+ const comment = videoState.commentsById.get(commentId);
3554
+ if (!comment) return;
3555
+ const commentsById = new Map(videoState.commentsById);
3556
+ commentsById.set(commentId, {
3557
+ ...comment,
3558
+ isLiked,
3559
+ likeCount: comment.likeCount + (isLiked ? 1 : -1)
3560
+ });
3561
+ this.updateVideoState(videoId, { commentsById });
3562
+ }
3563
+ }
3564
+ // ═══════════════════════════════════════════════════════════════
3565
+ // PRIVATE - UTILITY METHODS
3566
+ // ═══════════════════════════════════════════════════════════════
3567
+ updateVideoState(videoId, partial) {
3568
+ const state = this.store.getState();
3569
+ const existing = state.byVideoId.get(videoId) ?? createInitialVideoCommentState();
3570
+ const newByVideoId = new Map(state.byVideoId);
3571
+ newByVideoId.set(videoId, { ...existing, ...partial });
3572
+ this.store.setState({ byVideoId: newByVideoId });
3573
+ }
3574
+ isCacheStale(videoState) {
3575
+ if (videoState.isStale) return true;
3576
+ if (videoState.cachedAt === 0) return true;
3577
+ return Date.now() - videoState.cachedAt > this.config.cacheTTL;
3578
+ }
3579
+ generateOptimisticId() {
3580
+ return `optimistic_${Date.now()}_${++this.optimisticIdCounter}`;
3581
+ }
3582
+ createError(error) {
3583
+ const err = error instanceof Error ? error : new Error(String(error));
3584
+ let code = "UNKNOWN";
3585
+ let recoverable = true;
3586
+ if (err.message.includes("network") || err.message.includes("fetch")) {
3587
+ code = "NETWORK_ERROR";
3588
+ } else if (err.message.includes("timeout")) {
3589
+ code = "TIMEOUT";
3590
+ } else if (err.message.includes("404") || err.message.includes("not found")) {
3591
+ code = "NOT_FOUND";
3592
+ recoverable = false;
3593
+ } else if (err.message.includes("403") || err.message.includes("forbidden")) {
3594
+ code = "FORBIDDEN";
3595
+ recoverable = false;
3596
+ } else if (err.message.includes("429") || err.message.includes("rate limit")) {
3597
+ code = "RATE_LIMITED";
3598
+ } else if (err.message.includes("500") || err.message.includes("server")) {
3599
+ code = "SERVER_ERROR";
3600
+ }
3601
+ return {
3602
+ message: err.message,
3603
+ code,
3604
+ retryCount: 0,
3605
+ recoverable
3606
+ };
3607
+ }
3608
+ };
3609
+
3610
+ 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 };