@xhub-short/core 0.1.0-beta.1 → 0.1.0-beta.10
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 +393 -6
- package/dist/index.js +920 -19
- 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 {
|
|
@@ -252,6 +325,121 @@ var FeedManager = class {
|
|
|
252
325
|
});
|
|
253
326
|
}
|
|
254
327
|
// ═══════════════════════════════════════════════════════════════
|
|
328
|
+
// PREFETCH CACHE METHODS
|
|
329
|
+
// ═══════════════════════════════════════════════════════════════
|
|
330
|
+
/**
|
|
331
|
+
* Update prefetch cache with current feed tail
|
|
332
|
+
* Called automatically after loadInitial() and loadMore()
|
|
333
|
+
*
|
|
334
|
+
* Strategy: Cache the LAST N videos (tail of feed)
|
|
335
|
+
* These are videos user hasn't seen yet, perfect for instant display
|
|
336
|
+
*/
|
|
337
|
+
updatePrefetchCache() {
|
|
338
|
+
if (!this.prefetchConfig.enabled) return;
|
|
339
|
+
if (!this.storage) return;
|
|
340
|
+
const state = this.store.getState();
|
|
341
|
+
const allVideos = this.getVideos();
|
|
342
|
+
if (allVideos.length < this.prefetchConfig.maxVideos) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const tailVideos = allVideos.slice(-this.prefetchConfig.maxVideos);
|
|
346
|
+
const cacheData = {
|
|
347
|
+
items: tailVideos,
|
|
348
|
+
savedAt: Date.now(),
|
|
349
|
+
cursor: state.cursor
|
|
350
|
+
};
|
|
351
|
+
this.savePrefetchCacheAsync(cacheData);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Save prefetch cache to storage (async, non-blocking)
|
|
355
|
+
*/
|
|
356
|
+
async savePrefetchCacheAsync(data) {
|
|
357
|
+
if (!this.storage) return;
|
|
358
|
+
try {
|
|
359
|
+
await this.storage.set(this.prefetchConfig.storageKey, data);
|
|
360
|
+
} catch {
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Load prefetch cache from storage
|
|
365
|
+
* Returns null if no cache, disabled, or storage error
|
|
366
|
+
*/
|
|
367
|
+
async loadPrefetchCache() {
|
|
368
|
+
if (!this.prefetchConfig.enabled) return null;
|
|
369
|
+
if (!this.storage) return null;
|
|
370
|
+
try {
|
|
371
|
+
const data = await this.storage.get(this.prefetchConfig.storageKey);
|
|
372
|
+
if (!data || !data.items || data.items.length === 0) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
return data;
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Hydrate feed from prefetch cache for instant display
|
|
382
|
+
* Marks data as stale to trigger background revalidation
|
|
383
|
+
*/
|
|
384
|
+
hydrateFromPrefetchCache(cache) {
|
|
385
|
+
this.hydrateFromSnapshot(cache.items, cache.cursor, {
|
|
386
|
+
markAsStale: true
|
|
387
|
+
// Trigger revalidation
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Clear prefetch cache
|
|
392
|
+
* Call when user logs out or data should be invalidated
|
|
393
|
+
*/
|
|
394
|
+
async clearPrefetchCache() {
|
|
395
|
+
if (!this.storage) return;
|
|
396
|
+
try {
|
|
397
|
+
await this.storage.remove(this.prefetchConfig.storageKey);
|
|
398
|
+
} catch {
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Evict videos that user has scrolled past from prefetch cache
|
|
403
|
+
* Called when user's focusedIndex changes
|
|
404
|
+
*
|
|
405
|
+
* Strategy: Remove all videos at or before current position
|
|
406
|
+
* This ensures user doesn't rewatch videos on reload
|
|
407
|
+
*
|
|
408
|
+
* @param currentIndex - Current focused video index in feed
|
|
409
|
+
*/
|
|
410
|
+
async evictViewedVideosFromCache(currentIndex) {
|
|
411
|
+
if (!this.prefetchConfig.enabled) return;
|
|
412
|
+
if (!this.prefetchConfig.enableDynamicEviction) return;
|
|
413
|
+
if (!this.storage) return;
|
|
414
|
+
try {
|
|
415
|
+
const cache = await this.loadPrefetchCache();
|
|
416
|
+
if (!cache || cache.items.length === 0) return;
|
|
417
|
+
const allVideos = this.getVideos();
|
|
418
|
+
const currentVideo = allVideos[currentIndex];
|
|
419
|
+
if (!currentVideo) return;
|
|
420
|
+
const viewedVideoIds = new Set(allVideos.slice(0, currentIndex + 1).map((v) => v.id));
|
|
421
|
+
const updatedItems = cache.items.filter((item) => !viewedVideoIds.has(item.id));
|
|
422
|
+
if (updatedItems.length === cache.items.length) return;
|
|
423
|
+
if (updatedItems.length === 0) {
|
|
424
|
+
await this.storage.remove(this.prefetchConfig.storageKey);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const updatedCache = {
|
|
428
|
+
...cache,
|
|
429
|
+
items: updatedItems,
|
|
430
|
+
savedAt: Date.now()
|
|
431
|
+
};
|
|
432
|
+
await this.storage.set(this.prefetchConfig.storageKey, updatedCache);
|
|
433
|
+
} catch {
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Get prefetch cache configuration (for external access)
|
|
438
|
+
*/
|
|
439
|
+
getPrefetchConfig() {
|
|
440
|
+
return this.prefetchConfig;
|
|
441
|
+
}
|
|
442
|
+
// ═══════════════════════════════════════════════════════════════
|
|
255
443
|
// PRIVATE METHODS
|
|
256
444
|
// ═══════════════════════════════════════════════════════════════
|
|
257
445
|
/**
|
|
@@ -459,6 +647,12 @@ var FeedManager = class {
|
|
|
459
647
|
};
|
|
460
648
|
}
|
|
461
649
|
};
|
|
650
|
+
// ═══════════════════════════════════════════════════════════════
|
|
651
|
+
// STATIC MEMORY CACHE (PREFETCH)
|
|
652
|
+
// ═══════════════════════════════════════════════════════════════
|
|
653
|
+
/** Static memory cache for explicit prefetching */
|
|
654
|
+
_FeedManager.globalMemoryCache = null;
|
|
655
|
+
var FeedManager = _FeedManager;
|
|
462
656
|
|
|
463
657
|
// src/player/types.ts
|
|
464
658
|
var PlayerStatus = /* @__PURE__ */ ((PlayerStatus2) => {
|
|
@@ -546,7 +740,8 @@ var createInitialState2 = (config) => ({
|
|
|
546
740
|
ended: false,
|
|
547
741
|
// Session restore
|
|
548
742
|
pendingRestoreTime: null,
|
|
549
|
-
pendingRestoreVideoId: null
|
|
743
|
+
pendingRestoreVideoId: null,
|
|
744
|
+
pendingRestoreFrame: null
|
|
550
745
|
});
|
|
551
746
|
var PlayerEngine = class {
|
|
552
747
|
constructor(config = {}) {
|
|
@@ -595,7 +790,11 @@ var PlayerEngine = class {
|
|
|
595
790
|
}
|
|
596
791
|
return false;
|
|
597
792
|
}
|
|
598
|
-
const
|
|
793
|
+
const currentState = this.store.getState();
|
|
794
|
+
const currentStatus = currentState.status;
|
|
795
|
+
if (currentState.currentVideo && currentState.currentVideo.id !== video.id) {
|
|
796
|
+
this.trackLeaveVideo(currentState);
|
|
797
|
+
}
|
|
599
798
|
if (currentStatus !== "idle" /* IDLE */ && currentStatus !== "error" /* ERROR */ && currentStatus !== "paused" /* PAUSED */) {
|
|
600
799
|
if (!this.transitionTo("idle" /* IDLE */)) {
|
|
601
800
|
return false;
|
|
@@ -821,22 +1020,24 @@ var PlayerEngine = class {
|
|
|
821
1020
|
* ```typescript
|
|
822
1021
|
* // During session restore
|
|
823
1022
|
* if (result.playbackTime && result.currentVideoId) {
|
|
824
|
-
* playerEngine.setRestorePosition(result.currentVideoId, result.playbackTime);
|
|
1023
|
+
* playerEngine.setRestorePosition(result.currentVideoId, result.playbackTime, result.restoreFrame);
|
|
825
1024
|
* }
|
|
826
1025
|
* ```
|
|
827
1026
|
*/
|
|
828
|
-
setRestorePosition(videoId, time) {
|
|
1027
|
+
setRestorePosition(videoId, time, frame) {
|
|
829
1028
|
this.store.setState({
|
|
830
1029
|
pendingRestoreTime: time,
|
|
831
|
-
pendingRestoreVideoId: videoId
|
|
1030
|
+
pendingRestoreVideoId: videoId,
|
|
1031
|
+
pendingRestoreFrame: frame ?? null
|
|
832
1032
|
});
|
|
833
|
-
this.logger?.debug("[PlayerEngine] Set restore position", { videoId, time });
|
|
1033
|
+
this.logger?.debug("[PlayerEngine] Set restore position", { videoId, time, hasFrame: !!frame });
|
|
834
1034
|
}
|
|
835
1035
|
/**
|
|
836
1036
|
* Get and clear restore position for a video.
|
|
837
1037
|
*
|
|
838
1038
|
* VideoPlayer calls this when rendering to get startTime.
|
|
839
1039
|
* Position is cleared after being read to prevent reuse.
|
|
1040
|
+
* Note: restoreFrame is NOT cleared here - use consumeRestoreFrame() separately.
|
|
840
1041
|
*
|
|
841
1042
|
* @param videoId - Video ID to check
|
|
842
1043
|
* @returns Restore time in seconds, or null if no pending restore
|
|
@@ -846,14 +1047,51 @@ var PlayerEngine = class {
|
|
|
846
1047
|
if (state.pendingRestoreVideoId === videoId && state.pendingRestoreTime !== null) {
|
|
847
1048
|
const time = state.pendingRestoreTime;
|
|
848
1049
|
this.store.setState({
|
|
849
|
-
pendingRestoreTime: null
|
|
850
|
-
|
|
1050
|
+
pendingRestoreTime: null
|
|
1051
|
+
// Note: pendingRestoreVideoId and pendingRestoreFrame kept for frame consumption
|
|
851
1052
|
});
|
|
852
1053
|
this.logger?.debug("[PlayerEngine] Consumed restore position", { videoId, time });
|
|
853
1054
|
return time;
|
|
854
1055
|
}
|
|
855
1056
|
return null;
|
|
856
1057
|
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Get and clear restore frame for a video.
|
|
1060
|
+
*
|
|
1061
|
+
* VideoSlot calls this to display instant preview while video loads.
|
|
1062
|
+
* Frame is cleared after being read to free memory.
|
|
1063
|
+
*
|
|
1064
|
+
* @param videoId - Video ID to check
|
|
1065
|
+
* @returns Base64 JPEG data URL, or null if no pending frame
|
|
1066
|
+
*/
|
|
1067
|
+
consumeRestoreFrame(videoId) {
|
|
1068
|
+
const state = this.store.getState();
|
|
1069
|
+
if (state.pendingRestoreVideoId === videoId && state.pendingRestoreFrame !== null) {
|
|
1070
|
+
const frame = state.pendingRestoreFrame;
|
|
1071
|
+
this.store.setState({
|
|
1072
|
+
pendingRestoreFrame: null,
|
|
1073
|
+
pendingRestoreVideoId: null
|
|
1074
|
+
// Clear video ID since we've consumed both time and frame
|
|
1075
|
+
});
|
|
1076
|
+
this.logger?.debug("[PlayerEngine] Consumed restore frame", { videoId });
|
|
1077
|
+
return frame;
|
|
1078
|
+
}
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Get restore frame without consuming (peek).
|
|
1083
|
+
* Used to display preview while keeping it available.
|
|
1084
|
+
*
|
|
1085
|
+
* @param videoId - Video ID to check
|
|
1086
|
+
* @returns Base64 JPEG data URL, or null if no pending frame
|
|
1087
|
+
*/
|
|
1088
|
+
peekRestoreFrame(videoId) {
|
|
1089
|
+
const state = this.store.getState();
|
|
1090
|
+
if (state.pendingRestoreVideoId === videoId && state.pendingRestoreFrame !== null) {
|
|
1091
|
+
return state.pendingRestoreFrame;
|
|
1092
|
+
}
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
857
1095
|
/**
|
|
858
1096
|
* Check if there's a pending restore position for a video.
|
|
859
1097
|
*
|
|
@@ -964,13 +1202,23 @@ var PlayerEngine = class {
|
|
|
964
1202
|
}
|
|
965
1203
|
/**
|
|
966
1204
|
* Start watch time tracking
|
|
1205
|
+
* Increments watchTime every second and sends analytics heartbeat
|
|
967
1206
|
*/
|
|
968
1207
|
startWatchTimeTracking() {
|
|
969
1208
|
if (this.watchTimeInterval) return;
|
|
970
1209
|
this.watchTimeInterval = setInterval(() => {
|
|
971
1210
|
const state = this.store.getState();
|
|
972
|
-
if (state.status === "playing" /* PLAYING */) {
|
|
973
|
-
|
|
1211
|
+
if (state.status === "playing" /* PLAYING */ && state.currentVideo) {
|
|
1212
|
+
const newWatchTime = state.watchTime + 1;
|
|
1213
|
+
this.store.setState({ watchTime: newWatchTime });
|
|
1214
|
+
if (this.analytics && newWatchTime >= this.config.watchTimeThreshold) {
|
|
1215
|
+
this.analytics.trackViewDuration(
|
|
1216
|
+
state.currentVideo.id,
|
|
1217
|
+
state.currentTime,
|
|
1218
|
+
// Use playback position (currentTime) for API
|
|
1219
|
+
state.duration
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
974
1222
|
}
|
|
975
1223
|
}, 1e3);
|
|
976
1224
|
}
|
|
@@ -984,7 +1232,7 @@ var PlayerEngine = class {
|
|
|
984
1232
|
}
|
|
985
1233
|
}
|
|
986
1234
|
/**
|
|
987
|
-
* Track completion analytics
|
|
1235
|
+
* Track completion analytics (when video loops/ends)
|
|
988
1236
|
*/
|
|
989
1237
|
trackCompletion(state, loopCount) {
|
|
990
1238
|
if (!this.analytics || !state.currentVideo) return;
|
|
@@ -996,6 +1244,27 @@ var PlayerEngine = class {
|
|
|
996
1244
|
this.analytics.trackCompletion(state.currentVideo.id, state.watchTime, loopCount);
|
|
997
1245
|
}
|
|
998
1246
|
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Track when user leaves current video (scrolls to next video)
|
|
1249
|
+
* Sends final analytics event before video change
|
|
1250
|
+
*/
|
|
1251
|
+
trackLeaveVideo(state) {
|
|
1252
|
+
if (!this.analytics || !state.currentVideo) return;
|
|
1253
|
+
if (state.watchTime >= this.config.watchTimeThreshold) {
|
|
1254
|
+
this.analytics.trackViewDuration(
|
|
1255
|
+
state.currentVideo.id,
|
|
1256
|
+
state.currentTime,
|
|
1257
|
+
// Use currentTime (playback position) instead of watchTime
|
|
1258
|
+
state.duration
|
|
1259
|
+
);
|
|
1260
|
+
this.logger?.debug("[PlayerEngine] Tracked leave event", {
|
|
1261
|
+
videoId: state.currentVideo.id,
|
|
1262
|
+
watchTime: state.watchTime,
|
|
1263
|
+
currentTime: state.currentTime
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
this.stopWatchTimeTracking();
|
|
1267
|
+
}
|
|
999
1268
|
/**
|
|
1000
1269
|
* Categorize media error
|
|
1001
1270
|
*/
|
|
@@ -1331,6 +1600,9 @@ var LifecycleManager = class {
|
|
|
1331
1600
|
if (validSnapshot.currentVideoId !== void 0) {
|
|
1332
1601
|
result.currentVideoId = validSnapshot.currentVideoId;
|
|
1333
1602
|
}
|
|
1603
|
+
if (validSnapshot.restoreFrame !== void 0) {
|
|
1604
|
+
result.restoreFrame = validSnapshot.restoreFrame;
|
|
1605
|
+
}
|
|
1334
1606
|
}
|
|
1335
1607
|
this.store.setState({
|
|
1336
1608
|
isRestoring: false,
|
|
@@ -1341,7 +1613,8 @@ var LifecycleManager = class {
|
|
|
1341
1613
|
this.logger?.debug("[LifecycleManager] Session restored", {
|
|
1342
1614
|
itemCount: validSnapshot.items.length,
|
|
1343
1615
|
needsRevalidation,
|
|
1344
|
-
hasPlaybackTime: this.config.restorePlaybackPosition && validSnapshot.playbackTime !== void 0
|
|
1616
|
+
hasPlaybackTime: this.config.restorePlaybackPosition && validSnapshot.playbackTime !== void 0,
|
|
1617
|
+
hasRestoreFrame: this.config.restorePlaybackPosition && validSnapshot.restoreFrame !== void 0
|
|
1345
1618
|
});
|
|
1346
1619
|
return result;
|
|
1347
1620
|
} catch (error) {
|
|
@@ -1366,6 +1639,7 @@ var LifecycleManager = class {
|
|
|
1366
1639
|
* @param data - Snapshot data to save
|
|
1367
1640
|
* @param data.playbackTime - Current video playback position (only saved if restorePlaybackPosition config is enabled)
|
|
1368
1641
|
* @param data.currentVideoId - Current video ID (only saved if restorePlaybackPosition config is enabled)
|
|
1642
|
+
* @param data.restoreFrame - Captured video frame at playback position (only saved if restorePlaybackPosition is enabled)
|
|
1369
1643
|
*/
|
|
1370
1644
|
async saveSnapshot(data) {
|
|
1371
1645
|
if (!this.storage) {
|
|
@@ -1394,6 +1668,9 @@ var LifecycleManager = class {
|
|
|
1394
1668
|
if (data.currentVideoId !== void 0) {
|
|
1395
1669
|
snapshot.currentVideoId = data.currentVideoId;
|
|
1396
1670
|
}
|
|
1671
|
+
if (data.restoreFrame !== void 0) {
|
|
1672
|
+
snapshot.restoreFrame = data.restoreFrame;
|
|
1673
|
+
}
|
|
1397
1674
|
}
|
|
1398
1675
|
await this.storage.saveSnapshot(snapshot);
|
|
1399
1676
|
const timestamp = Date.now();
|
|
@@ -1404,7 +1681,8 @@ var LifecycleManager = class {
|
|
|
1404
1681
|
this.emitEvent({ type: "saveComplete", timestamp });
|
|
1405
1682
|
this.logger?.debug("[LifecycleManager] Snapshot saved", {
|
|
1406
1683
|
itemCount: data.items.length,
|
|
1407
|
-
hasPlaybackTime: this.config.restorePlaybackPosition && data.playbackTime !== void 0
|
|
1684
|
+
hasPlaybackTime: this.config.restorePlaybackPosition && data.playbackTime !== void 0,
|
|
1685
|
+
hasRestoreFrame: this.config.restorePlaybackPosition && data.restoreFrame !== void 0
|
|
1408
1686
|
});
|
|
1409
1687
|
return true;
|
|
1410
1688
|
} catch (error) {
|
|
@@ -2670,4 +2948,627 @@ var OptimisticManager = class {
|
|
|
2670
2948
|
}
|
|
2671
2949
|
};
|
|
2672
2950
|
|
|
2673
|
-
|
|
2951
|
+
// src/comment/types.ts
|
|
2952
|
+
var DEFAULT_COMMENT_MANAGER_CONFIG = {
|
|
2953
|
+
pageSize: 20,
|
|
2954
|
+
repliesPageSize: 10,
|
|
2955
|
+
cacheTTL: 5 * 60 * 1e3,
|
|
2956
|
+
// 5 minutes
|
|
2957
|
+
maxRetries: 2,
|
|
2958
|
+
repliesAutoExpandThreshold: 1,
|
|
2959
|
+
repliesCollapseLimit: 3,
|
|
2960
|
+
textMaxLines: 3,
|
|
2961
|
+
enableOptimistic: true
|
|
2962
|
+
};
|
|
2963
|
+
var createInitialVideoCommentState = () => ({
|
|
2964
|
+
commentsById: /* @__PURE__ */ new Map(),
|
|
2965
|
+
displayOrder: [],
|
|
2966
|
+
totalCount: 0,
|
|
2967
|
+
cursor: null,
|
|
2968
|
+
hasMore: true,
|
|
2969
|
+
loading: false,
|
|
2970
|
+
loadingMore: false,
|
|
2971
|
+
error: null,
|
|
2972
|
+
cachedAt: 0,
|
|
2973
|
+
isStale: true
|
|
2974
|
+
});
|
|
2975
|
+
var createInitialCommentState = () => ({
|
|
2976
|
+
byVideoId: /* @__PURE__ */ new Map(),
|
|
2977
|
+
activeVideoId: null,
|
|
2978
|
+
isPosting: false,
|
|
2979
|
+
postError: null,
|
|
2980
|
+
deletingId: null
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
// src/comment/CommentManager.ts
|
|
2984
|
+
var CommentManager = class {
|
|
2985
|
+
constructor(adapter, config = {}, logger) {
|
|
2986
|
+
this.adapter = adapter;
|
|
2987
|
+
/** Request deduplication: Map of key → in-flight Promise */
|
|
2988
|
+
this.inFlightRequests = /* @__PURE__ */ new Map();
|
|
2989
|
+
/** Optimistic ID counter */
|
|
2990
|
+
this.optimisticIdCounter = 0;
|
|
2991
|
+
this.config = { ...DEFAULT_COMMENT_MANAGER_CONFIG, ...config };
|
|
2992
|
+
this.store = createStore(createInitialCommentState);
|
|
2993
|
+
this.logger = logger;
|
|
2994
|
+
}
|
|
2995
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2996
|
+
// PUBLIC API - READ OPERATIONS
|
|
2997
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2998
|
+
/**
|
|
2999
|
+
* Load comments for a video
|
|
3000
|
+
*
|
|
3001
|
+
* Features:
|
|
3002
|
+
* - Cache check with TTL
|
|
3003
|
+
* - Request deduplication
|
|
3004
|
+
* - Stale data detection
|
|
3005
|
+
*/
|
|
3006
|
+
async loadComments(videoId, forceRefresh = false) {
|
|
3007
|
+
const dedupeKey = `loadComments:${videoId}`;
|
|
3008
|
+
const existingRequest = this.inFlightRequests.get(dedupeKey);
|
|
3009
|
+
if (existingRequest) {
|
|
3010
|
+
this.logger?.debug(`[CommentManager] Deduped loadComments: ${videoId}`);
|
|
3011
|
+
return existingRequest;
|
|
3012
|
+
}
|
|
3013
|
+
const state = this.store.getState();
|
|
3014
|
+
const videoState = state.byVideoId.get(videoId);
|
|
3015
|
+
if (!forceRefresh && videoState && !this.isCacheStale(videoState)) {
|
|
3016
|
+
this.logger?.debug(`[CommentManager] Using cached comments: ${videoId}`);
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
const request = this.executeLoadComments(videoId, forceRefresh);
|
|
3020
|
+
this.inFlightRequests.set(dedupeKey, request);
|
|
3021
|
+
try {
|
|
3022
|
+
await request;
|
|
3023
|
+
} finally {
|
|
3024
|
+
this.inFlightRequests.delete(dedupeKey);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Load more comments (pagination)
|
|
3029
|
+
*/
|
|
3030
|
+
async loadMore(videoId) {
|
|
3031
|
+
const state = this.store.getState();
|
|
3032
|
+
const videoState = state.byVideoId.get(videoId);
|
|
3033
|
+
if (!videoState || videoState.loading || videoState.loadingMore || !videoState.hasMore) {
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
const dedupeKey = `loadMore:${videoId}:${videoState.cursor}`;
|
|
3037
|
+
const existingRequest = this.inFlightRequests.get(dedupeKey);
|
|
3038
|
+
if (existingRequest) {
|
|
3039
|
+
return existingRequest;
|
|
3040
|
+
}
|
|
3041
|
+
const request = this.executeLoadMore(videoId);
|
|
3042
|
+
this.inFlightRequests.set(dedupeKey, request);
|
|
3043
|
+
try {
|
|
3044
|
+
await request;
|
|
3045
|
+
} finally {
|
|
3046
|
+
this.inFlightRequests.delete(dedupeKey);
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Load replies for a comment
|
|
3051
|
+
*/
|
|
3052
|
+
async loadReplies(commentId) {
|
|
3053
|
+
const dedupeKey = `loadReplies:${commentId}`;
|
|
3054
|
+
const existingRequest = this.inFlightRequests.get(dedupeKey);
|
|
3055
|
+
if (existingRequest) {
|
|
3056
|
+
return existingRequest;
|
|
3057
|
+
}
|
|
3058
|
+
const request = this.executeLoadReplies(commentId);
|
|
3059
|
+
this.inFlightRequests.set(dedupeKey, request);
|
|
3060
|
+
try {
|
|
3061
|
+
await request;
|
|
3062
|
+
} finally {
|
|
3063
|
+
this.inFlightRequests.delete(dedupeKey);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3067
|
+
// PUBLIC API - WRITE OPERATIONS
|
|
3068
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3069
|
+
/**
|
|
3070
|
+
* Post a new comment with optimistic UI
|
|
3071
|
+
*/
|
|
3072
|
+
async postComment(videoId, content, currentUser) {
|
|
3073
|
+
if (!content.trim()) return null;
|
|
3074
|
+
const optimisticId = this.generateOptimisticId();
|
|
3075
|
+
const optimisticComment = {
|
|
3076
|
+
id: optimisticId,
|
|
3077
|
+
videoId,
|
|
3078
|
+
content: content.trim(),
|
|
3079
|
+
author: currentUser,
|
|
3080
|
+
likeCount: 0,
|
|
3081
|
+
isLiked: false,
|
|
3082
|
+
replyCount: 0,
|
|
3083
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3084
|
+
isOwner: true,
|
|
3085
|
+
isPending: true
|
|
3086
|
+
};
|
|
3087
|
+
if (this.config.enableOptimistic) {
|
|
3088
|
+
this.addOptimisticComment(videoId, optimisticComment);
|
|
3089
|
+
}
|
|
3090
|
+
this.store.setState({ isPosting: true, postError: null });
|
|
3091
|
+
try {
|
|
3092
|
+
const realComment = await this.adapter.postComment({ videoId, content: content.trim() });
|
|
3093
|
+
this.replaceOptimisticComment(videoId, optimisticId, realComment);
|
|
3094
|
+
this.logger?.debug(`[CommentManager] Posted comment: ${realComment.id}`);
|
|
3095
|
+
return realComment;
|
|
3096
|
+
} catch (error) {
|
|
3097
|
+
if (this.config.enableOptimistic) {
|
|
3098
|
+
this.removeOptimisticComment(videoId, optimisticId);
|
|
3099
|
+
}
|
|
3100
|
+
const commentError = this.createError(error);
|
|
3101
|
+
this.store.setState({ postError: commentError });
|
|
3102
|
+
this.logger?.warn("[CommentManager] Post failed", { error: commentError.message });
|
|
3103
|
+
return null;
|
|
3104
|
+
} finally {
|
|
3105
|
+
this.store.setState({ isPosting: false });
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* Post a reply with optimistic UI
|
|
3110
|
+
*/
|
|
3111
|
+
async postReply(videoId, parentId, content, currentUser, replyTo) {
|
|
3112
|
+
if (!content.trim()) return null;
|
|
3113
|
+
const optimisticId = this.generateOptimisticId();
|
|
3114
|
+
const optimisticReply = {
|
|
3115
|
+
id: optimisticId,
|
|
3116
|
+
parentId,
|
|
3117
|
+
content: content.trim(),
|
|
3118
|
+
author: currentUser,
|
|
3119
|
+
likeCount: 0,
|
|
3120
|
+
isLiked: false,
|
|
3121
|
+
replyTo,
|
|
3122
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3123
|
+
isOwner: true,
|
|
3124
|
+
isPending: true
|
|
3125
|
+
};
|
|
3126
|
+
if (this.config.enableOptimistic) {
|
|
3127
|
+
this.addOptimisticReply(videoId, parentId, optimisticReply);
|
|
3128
|
+
}
|
|
3129
|
+
this.store.setState({ isPosting: true, postError: null });
|
|
3130
|
+
try {
|
|
3131
|
+
const realReply = await this.adapter.postReply({
|
|
3132
|
+
videoId,
|
|
3133
|
+
parentId,
|
|
3134
|
+
content: content.trim(),
|
|
3135
|
+
replyToUserId: replyTo?.id
|
|
3136
|
+
});
|
|
3137
|
+
this.replaceOptimisticReply(videoId, parentId, optimisticId, realReply);
|
|
3138
|
+
this.logger?.debug(`[CommentManager] Posted reply: ${realReply.id}`);
|
|
3139
|
+
return realReply;
|
|
3140
|
+
} catch (error) {
|
|
3141
|
+
if (this.config.enableOptimistic) {
|
|
3142
|
+
this.removeOptimisticReply(videoId, parentId, optimisticId);
|
|
3143
|
+
}
|
|
3144
|
+
const commentError = this.createError(error);
|
|
3145
|
+
this.store.setState({ postError: commentError });
|
|
3146
|
+
this.logger?.warn("[CommentManager] Reply failed", { error: commentError.message });
|
|
3147
|
+
return null;
|
|
3148
|
+
} finally {
|
|
3149
|
+
this.store.setState({ isPosting: false });
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Delete a comment with optimistic UI
|
|
3154
|
+
*/
|
|
3155
|
+
async deleteComment(videoId, commentId, isReply = false, parentId) {
|
|
3156
|
+
const backup = isReply ? this.getReply(videoId, parentId, commentId) : this.getComment(videoId, commentId);
|
|
3157
|
+
if (!backup) return false;
|
|
3158
|
+
if (this.config.enableOptimistic) {
|
|
3159
|
+
this.store.setState({ deletingId: commentId });
|
|
3160
|
+
if (isReply && parentId) {
|
|
3161
|
+
this.removeReplyFromStore(videoId, parentId, commentId);
|
|
3162
|
+
} else {
|
|
3163
|
+
this.removeCommentFromStore(videoId, commentId);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
try {
|
|
3167
|
+
await this.adapter.deleteComment({
|
|
3168
|
+
videoId,
|
|
3169
|
+
commentId,
|
|
3170
|
+
isReply,
|
|
3171
|
+
parentId
|
|
3172
|
+
});
|
|
3173
|
+
this.logger?.debug(`[CommentManager] Deleted ${isReply ? "reply" : "comment"}: ${commentId}`);
|
|
3174
|
+
return true;
|
|
3175
|
+
} catch (error) {
|
|
3176
|
+
if (this.config.enableOptimistic) {
|
|
3177
|
+
if (isReply && parentId) {
|
|
3178
|
+
this.restoreReply(videoId, parentId, backup);
|
|
3179
|
+
} else {
|
|
3180
|
+
this.restoreComment(videoId, backup);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
this.logger?.warn(`[CommentManager] Delete failed: ${commentId}`, {
|
|
3184
|
+
error: error.message
|
|
3185
|
+
});
|
|
3186
|
+
return false;
|
|
3187
|
+
} finally {
|
|
3188
|
+
this.store.setState({ deletingId: null });
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* Like a comment/reply with optimistic UI
|
|
3193
|
+
*/
|
|
3194
|
+
async likeComment(videoId, commentId, isReply = false, parentId) {
|
|
3195
|
+
this.updateLikeState(videoId, commentId, true, isReply, parentId);
|
|
3196
|
+
try {
|
|
3197
|
+
await this.adapter.likeComment(commentId);
|
|
3198
|
+
} catch (error) {
|
|
3199
|
+
this.updateLikeState(videoId, commentId, false, isReply, parentId);
|
|
3200
|
+
this.logger?.warn(`[CommentManager] Like failed: ${commentId}`, {
|
|
3201
|
+
error: error.message
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
/**
|
|
3206
|
+
* Unlike a comment/reply with optimistic UI
|
|
3207
|
+
*/
|
|
3208
|
+
async unlikeComment(videoId, commentId, isReply = false, parentId) {
|
|
3209
|
+
this.updateLikeState(videoId, commentId, false, isReply, parentId);
|
|
3210
|
+
try {
|
|
3211
|
+
await this.adapter.unlikeComment(commentId);
|
|
3212
|
+
} catch (error) {
|
|
3213
|
+
this.updateLikeState(videoId, commentId, true, isReply, parentId);
|
|
3214
|
+
this.logger?.warn(`[CommentManager] Unlike failed: ${commentId}`, {
|
|
3215
|
+
error: error.message
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3220
|
+
// PUBLIC API - STATE ACCESSORS
|
|
3221
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3222
|
+
/**
|
|
3223
|
+
* Get comments for a video
|
|
3224
|
+
*/
|
|
3225
|
+
getComments(videoId) {
|
|
3226
|
+
const state = this.store.getState();
|
|
3227
|
+
const videoState = state.byVideoId.get(videoId);
|
|
3228
|
+
if (!videoState) return [];
|
|
3229
|
+
return videoState.displayOrder.map((id) => videoState.commentsById.get(id)).filter((c) => c !== void 0);
|
|
3230
|
+
}
|
|
3231
|
+
/**
|
|
3232
|
+
* Get a single comment
|
|
3233
|
+
*/
|
|
3234
|
+
getComment(videoId, commentId) {
|
|
3235
|
+
const state = this.store.getState();
|
|
3236
|
+
return state.byVideoId.get(videoId)?.commentsById.get(commentId);
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Get a single reply
|
|
3240
|
+
*/
|
|
3241
|
+
getReply(videoId, parentId, replyId) {
|
|
3242
|
+
const comment = this.getComment(videoId, parentId);
|
|
3243
|
+
return comment?.replies?.find((r) => r.id === replyId);
|
|
3244
|
+
}
|
|
3245
|
+
/**
|
|
3246
|
+
* Get video comment state
|
|
3247
|
+
*/
|
|
3248
|
+
getVideoState(videoId) {
|
|
3249
|
+
return this.store.getState().byVideoId.get(videoId);
|
|
3250
|
+
}
|
|
3251
|
+
/**
|
|
3252
|
+
* Set active video ID (for comment sheet)
|
|
3253
|
+
*/
|
|
3254
|
+
setActiveVideo(videoId) {
|
|
3255
|
+
this.store.setState({ activeVideoId: videoId });
|
|
3256
|
+
}
|
|
3257
|
+
/**
|
|
3258
|
+
* Get config
|
|
3259
|
+
*/
|
|
3260
|
+
getConfig() {
|
|
3261
|
+
return this.config;
|
|
3262
|
+
}
|
|
3263
|
+
/**
|
|
3264
|
+
* Clear comments for a video
|
|
3265
|
+
*/
|
|
3266
|
+
clearVideoComments(videoId) {
|
|
3267
|
+
const state = this.store.getState();
|
|
3268
|
+
const newByVideoId = new Map(state.byVideoId);
|
|
3269
|
+
newByVideoId.delete(videoId);
|
|
3270
|
+
this.store.setState({ byVideoId: newByVideoId });
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* Clear all comments
|
|
3274
|
+
*/
|
|
3275
|
+
clearAll() {
|
|
3276
|
+
this.store.setState(createInitialCommentState());
|
|
3277
|
+
}
|
|
3278
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3279
|
+
// PRIVATE - EXECUTE METHODS
|
|
3280
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3281
|
+
async executeLoadComments(videoId, _forceRefresh) {
|
|
3282
|
+
this.updateVideoState(videoId, { loading: true, error: null });
|
|
3283
|
+
try {
|
|
3284
|
+
const response = await this.adapter.getComments(videoId, null, this.config.pageSize);
|
|
3285
|
+
const commentsById = /* @__PURE__ */ new Map();
|
|
3286
|
+
const displayOrder = [];
|
|
3287
|
+
for (const comment of response.items) {
|
|
3288
|
+
commentsById.set(comment.id, comment);
|
|
3289
|
+
displayOrder.push(comment.id);
|
|
3290
|
+
}
|
|
3291
|
+
this.updateVideoState(videoId, {
|
|
3292
|
+
commentsById,
|
|
3293
|
+
displayOrder,
|
|
3294
|
+
totalCount: response.totalCount,
|
|
3295
|
+
cursor: response.nextCursor,
|
|
3296
|
+
hasMore: response.hasMore,
|
|
3297
|
+
loading: false,
|
|
3298
|
+
cachedAt: Date.now(),
|
|
3299
|
+
isStale: false
|
|
3300
|
+
});
|
|
3301
|
+
this.logger?.debug(
|
|
3302
|
+
`[CommentManager] Loaded ${response.items.length} comments for ${videoId}`
|
|
3303
|
+
);
|
|
3304
|
+
} catch (error) {
|
|
3305
|
+
const commentError = this.createError(error);
|
|
3306
|
+
this.updateVideoState(videoId, { error: commentError, loading: false });
|
|
3307
|
+
throw error;
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
async executeLoadMore(videoId) {
|
|
3311
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3312
|
+
if (!videoState) return;
|
|
3313
|
+
this.updateVideoState(videoId, { loadingMore: true });
|
|
3314
|
+
try {
|
|
3315
|
+
const response = await this.adapter.getComments(
|
|
3316
|
+
videoId,
|
|
3317
|
+
videoState.cursor,
|
|
3318
|
+
this.config.pageSize
|
|
3319
|
+
);
|
|
3320
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3321
|
+
const displayOrder = [...videoState.displayOrder];
|
|
3322
|
+
for (const comment of response.items) {
|
|
3323
|
+
if (!commentsById.has(comment.id)) {
|
|
3324
|
+
commentsById.set(comment.id, comment);
|
|
3325
|
+
displayOrder.push(comment.id);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
this.updateVideoState(videoId, {
|
|
3329
|
+
commentsById,
|
|
3330
|
+
displayOrder,
|
|
3331
|
+
cursor: response.nextCursor,
|
|
3332
|
+
hasMore: response.hasMore,
|
|
3333
|
+
loadingMore: false
|
|
3334
|
+
});
|
|
3335
|
+
this.logger?.debug(`[CommentManager] Loaded ${response.items.length} more comments`);
|
|
3336
|
+
} catch (error) {
|
|
3337
|
+
this.updateVideoState(videoId, { loadingMore: false });
|
|
3338
|
+
throw error;
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
async executeLoadReplies(commentId) {
|
|
3342
|
+
try {
|
|
3343
|
+
const response = await this.adapter.getReplies(commentId, null, this.config.repliesPageSize);
|
|
3344
|
+
const state = this.store.getState();
|
|
3345
|
+
for (const [videoId, videoState] of state.byVideoId) {
|
|
3346
|
+
const comment = videoState.commentsById.get(commentId);
|
|
3347
|
+
if (comment) {
|
|
3348
|
+
const updatedComment = {
|
|
3349
|
+
...comment,
|
|
3350
|
+
replies: response.items,
|
|
3351
|
+
repliesCursor: response.nextCursor,
|
|
3352
|
+
repliesLoaded: !response.hasMore
|
|
3353
|
+
};
|
|
3354
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3355
|
+
commentsById.set(commentId, updatedComment);
|
|
3356
|
+
this.updateVideoState(videoId, { commentsById });
|
|
3357
|
+
this.logger?.debug(
|
|
3358
|
+
`[CommentManager] Loaded ${response.items.length} replies for ${commentId}`
|
|
3359
|
+
);
|
|
3360
|
+
break;
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
} catch (error) {
|
|
3364
|
+
this.logger?.warn(`[CommentManager] Load replies failed: ${commentId}`);
|
|
3365
|
+
throw error;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3369
|
+
// PRIVATE - OPTIMISTIC UPDATE HELPERS
|
|
3370
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3371
|
+
addOptimisticComment(videoId, comment) {
|
|
3372
|
+
const state = this.store.getState();
|
|
3373
|
+
const videoState = state.byVideoId.get(videoId) ?? createInitialVideoCommentState();
|
|
3374
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3375
|
+
commentsById.set(comment.id, comment);
|
|
3376
|
+
const displayOrder = [comment.id, ...videoState.displayOrder];
|
|
3377
|
+
this.updateVideoState(videoId, {
|
|
3378
|
+
commentsById,
|
|
3379
|
+
displayOrder,
|
|
3380
|
+
totalCount: videoState.totalCount + 1
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
removeOptimisticComment(videoId, optimisticId) {
|
|
3384
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3385
|
+
if (!videoState) return;
|
|
3386
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3387
|
+
commentsById.delete(optimisticId);
|
|
3388
|
+
const displayOrder = videoState.displayOrder.filter((id) => id !== optimisticId);
|
|
3389
|
+
this.updateVideoState(videoId, {
|
|
3390
|
+
commentsById,
|
|
3391
|
+
displayOrder,
|
|
3392
|
+
totalCount: Math.max(0, videoState.totalCount - 1)
|
|
3393
|
+
});
|
|
3394
|
+
}
|
|
3395
|
+
replaceOptimisticComment(videoId, optimisticId, realComment) {
|
|
3396
|
+
const state = this.store.getState();
|
|
3397
|
+
const videoState = state.byVideoId.get(videoId) ?? createInitialVideoCommentState();
|
|
3398
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3399
|
+
commentsById.delete(optimisticId);
|
|
3400
|
+
commentsById.set(realComment.id, realComment);
|
|
3401
|
+
let displayOrder;
|
|
3402
|
+
if (videoState.displayOrder.includes(optimisticId)) {
|
|
3403
|
+
displayOrder = videoState.displayOrder.map(
|
|
3404
|
+
(id) => id === optimisticId ? realComment.id : id
|
|
3405
|
+
);
|
|
3406
|
+
} else {
|
|
3407
|
+
displayOrder = [realComment.id, ...videoState.displayOrder];
|
|
3408
|
+
}
|
|
3409
|
+
this.updateVideoState(videoId, { commentsById, displayOrder });
|
|
3410
|
+
}
|
|
3411
|
+
addOptimisticReply(videoId, parentId, reply) {
|
|
3412
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3413
|
+
if (!videoState) return;
|
|
3414
|
+
const comment = videoState.commentsById.get(parentId);
|
|
3415
|
+
if (!comment) return;
|
|
3416
|
+
const updatedComment = {
|
|
3417
|
+
...comment,
|
|
3418
|
+
replies: [...comment.replies ?? [], reply],
|
|
3419
|
+
replyCount: comment.replyCount + 1
|
|
3420
|
+
};
|
|
3421
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3422
|
+
commentsById.set(parentId, updatedComment);
|
|
3423
|
+
this.updateVideoState(videoId, { commentsById });
|
|
3424
|
+
}
|
|
3425
|
+
removeOptimisticReply(videoId, parentId, optimisticId) {
|
|
3426
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3427
|
+
if (!videoState) return;
|
|
3428
|
+
const comment = videoState.commentsById.get(parentId);
|
|
3429
|
+
if (!comment) return;
|
|
3430
|
+
const updatedComment = {
|
|
3431
|
+
...comment,
|
|
3432
|
+
replies: comment.replies?.filter((r) => r.id !== optimisticId),
|
|
3433
|
+
replyCount: Math.max(0, comment.replyCount - 1)
|
|
3434
|
+
};
|
|
3435
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3436
|
+
commentsById.set(parentId, updatedComment);
|
|
3437
|
+
this.updateVideoState(videoId, { commentsById });
|
|
3438
|
+
}
|
|
3439
|
+
replaceOptimisticReply(videoId, parentId, optimisticId, realReply) {
|
|
3440
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3441
|
+
if (!videoState) return;
|
|
3442
|
+
const comment = videoState.commentsById.get(parentId);
|
|
3443
|
+
if (!comment) return;
|
|
3444
|
+
const updatedComment = {
|
|
3445
|
+
...comment,
|
|
3446
|
+
replies: comment.replies?.map((r) => r.id === optimisticId ? realReply : r)
|
|
3447
|
+
};
|
|
3448
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3449
|
+
commentsById.set(parentId, updatedComment);
|
|
3450
|
+
this.updateVideoState(videoId, { commentsById });
|
|
3451
|
+
}
|
|
3452
|
+
removeCommentFromStore(videoId, commentId) {
|
|
3453
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3454
|
+
if (!videoState) return;
|
|
3455
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3456
|
+
commentsById.delete(commentId);
|
|
3457
|
+
const displayOrder = videoState.displayOrder.filter((id) => id !== commentId);
|
|
3458
|
+
this.updateVideoState(videoId, {
|
|
3459
|
+
commentsById,
|
|
3460
|
+
displayOrder,
|
|
3461
|
+
totalCount: Math.max(0, videoState.totalCount - 1)
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
removeReplyFromStore(videoId, parentId, replyId) {
|
|
3465
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3466
|
+
if (!videoState) return;
|
|
3467
|
+
const comment = videoState.commentsById.get(parentId);
|
|
3468
|
+
if (!comment) return;
|
|
3469
|
+
const updatedComment = {
|
|
3470
|
+
...comment,
|
|
3471
|
+
replies: comment.replies?.filter((r) => r.id !== replyId),
|
|
3472
|
+
replyCount: Math.max(0, comment.replyCount - 1)
|
|
3473
|
+
};
|
|
3474
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3475
|
+
commentsById.set(parentId, updatedComment);
|
|
3476
|
+
this.updateVideoState(videoId, { commentsById });
|
|
3477
|
+
}
|
|
3478
|
+
restoreComment(videoId, comment) {
|
|
3479
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3480
|
+
if (!videoState) return;
|
|
3481
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3482
|
+
commentsById.set(comment.id, comment);
|
|
3483
|
+
const displayOrder = videoState.displayOrder.includes(comment.id) ? videoState.displayOrder : [comment.id, ...videoState.displayOrder];
|
|
3484
|
+
this.updateVideoState(videoId, {
|
|
3485
|
+
commentsById,
|
|
3486
|
+
displayOrder,
|
|
3487
|
+
totalCount: videoState.totalCount + 1
|
|
3488
|
+
});
|
|
3489
|
+
}
|
|
3490
|
+
restoreReply(videoId, parentId, reply) {
|
|
3491
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3492
|
+
if (!videoState) return;
|
|
3493
|
+
const comment = videoState.commentsById.get(parentId);
|
|
3494
|
+
if (!comment) return;
|
|
3495
|
+
const updatedComment = {
|
|
3496
|
+
...comment,
|
|
3497
|
+
replies: [...comment.replies ?? [], reply],
|
|
3498
|
+
replyCount: comment.replyCount + 1
|
|
3499
|
+
};
|
|
3500
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3501
|
+
commentsById.set(parentId, updatedComment);
|
|
3502
|
+
this.updateVideoState(videoId, { commentsById });
|
|
3503
|
+
}
|
|
3504
|
+
updateLikeState(videoId, commentId, isLiked, isReply, parentId) {
|
|
3505
|
+
const videoState = this.store.getState().byVideoId.get(videoId);
|
|
3506
|
+
if (!videoState) return;
|
|
3507
|
+
if (isReply && parentId) {
|
|
3508
|
+
const comment = videoState.commentsById.get(parentId);
|
|
3509
|
+
if (!comment) return;
|
|
3510
|
+
const updatedReplies = comment.replies?.map(
|
|
3511
|
+
(r) => r.id === commentId ? { ...r, isLiked, likeCount: r.likeCount + (isLiked ? 1 : -1) } : r
|
|
3512
|
+
);
|
|
3513
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3514
|
+
commentsById.set(parentId, { ...comment, replies: updatedReplies });
|
|
3515
|
+
this.updateVideoState(videoId, { commentsById });
|
|
3516
|
+
} else {
|
|
3517
|
+
const comment = videoState.commentsById.get(commentId);
|
|
3518
|
+
if (!comment) return;
|
|
3519
|
+
const commentsById = new Map(videoState.commentsById);
|
|
3520
|
+
commentsById.set(commentId, {
|
|
3521
|
+
...comment,
|
|
3522
|
+
isLiked,
|
|
3523
|
+
likeCount: comment.likeCount + (isLiked ? 1 : -1)
|
|
3524
|
+
});
|
|
3525
|
+
this.updateVideoState(videoId, { commentsById });
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3529
|
+
// PRIVATE - UTILITY METHODS
|
|
3530
|
+
// ═══════════════════════════════════════════════════════════════
|
|
3531
|
+
updateVideoState(videoId, partial) {
|
|
3532
|
+
const state = this.store.getState();
|
|
3533
|
+
const existing = state.byVideoId.get(videoId) ?? createInitialVideoCommentState();
|
|
3534
|
+
const newByVideoId = new Map(state.byVideoId);
|
|
3535
|
+
newByVideoId.set(videoId, { ...existing, ...partial });
|
|
3536
|
+
this.store.setState({ byVideoId: newByVideoId });
|
|
3537
|
+
}
|
|
3538
|
+
isCacheStale(videoState) {
|
|
3539
|
+
if (videoState.isStale) return true;
|
|
3540
|
+
if (videoState.cachedAt === 0) return true;
|
|
3541
|
+
return Date.now() - videoState.cachedAt > this.config.cacheTTL;
|
|
3542
|
+
}
|
|
3543
|
+
generateOptimisticId() {
|
|
3544
|
+
return `optimistic_${Date.now()}_${++this.optimisticIdCounter}`;
|
|
3545
|
+
}
|
|
3546
|
+
createError(error) {
|
|
3547
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3548
|
+
let code = "UNKNOWN";
|
|
3549
|
+
let recoverable = true;
|
|
3550
|
+
if (err.message.includes("network") || err.message.includes("fetch")) {
|
|
3551
|
+
code = "NETWORK_ERROR";
|
|
3552
|
+
} else if (err.message.includes("timeout")) {
|
|
3553
|
+
code = "TIMEOUT";
|
|
3554
|
+
} else if (err.message.includes("404") || err.message.includes("not found")) {
|
|
3555
|
+
code = "NOT_FOUND";
|
|
3556
|
+
recoverable = false;
|
|
3557
|
+
} else if (err.message.includes("403") || err.message.includes("forbidden")) {
|
|
3558
|
+
code = "FORBIDDEN";
|
|
3559
|
+
recoverable = false;
|
|
3560
|
+
} else if (err.message.includes("429") || err.message.includes("rate limit")) {
|
|
3561
|
+
code = "RATE_LIMITED";
|
|
3562
|
+
} else if (err.message.includes("500") || err.message.includes("server")) {
|
|
3563
|
+
code = "SERVER_ERROR";
|
|
3564
|
+
}
|
|
3565
|
+
return {
|
|
3566
|
+
message: err.message,
|
|
3567
|
+
code,
|
|
3568
|
+
retryCount: 0,
|
|
3569
|
+
recoverable
|
|
3570
|
+
};
|
|
3571
|
+
}
|
|
3572
|
+
};
|
|
3573
|
+
|
|
3574
|
+
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 };
|