@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.d.ts +413 -6
- package/dist/index.js +957 -20
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|