@xhub-short/core 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +1466 -0
  2. package/dist/index.js +2495 -0
  3. package/package.json +41 -0
package/dist/index.js ADDED
@@ -0,0 +1,2495 @@
1
+ import { createStore } from 'zustand/vanilla';
2
+
3
+ // src/feed/FeedManager.ts
4
+
5
+ // src/feed/types.ts
6
+ var DEFAULT_FEED_CONFIG = {
7
+ pageSize: 10,
8
+ maxRetries: 3,
9
+ retryBaseDelay: 1e3,
10
+ staleTime: 5 * 60 * 1e3,
11
+ // 5 minutes
12
+ enableSWR: true,
13
+ maxCacheSize: 100,
14
+ enableGC: true
15
+ };
16
+
17
+ // src/feed/FeedManager.ts
18
+ var createInitialState = () => ({
19
+ itemsById: /* @__PURE__ */ new Map(),
20
+ displayOrder: [],
21
+ loading: false,
22
+ loadingMore: false,
23
+ error: null,
24
+ cursor: null,
25
+ hasMore: true,
26
+ isStale: false,
27
+ lastFetchTime: null
28
+ });
29
+ var FeedManager = class {
30
+ constructor(dataSource, config = {}) {
31
+ this.dataSource = dataSource;
32
+ /** Abort controller for cancelling in-flight requests */
33
+ this.abortController = null;
34
+ /**
35
+ * Request deduplication: Map of cursor → in-flight Promise
36
+ * Prevents duplicate API calls for the same cursor
37
+ */
38
+ this.inFlightRequests = /* @__PURE__ */ new Map();
39
+ /**
40
+ * LRU tracking: Map of videoId → lastAccessTime
41
+ * Used for garbage collection
42
+ */
43
+ this.accessOrder = /* @__PURE__ */ new Map();
44
+ this.config = { ...DEFAULT_FEED_CONFIG, ...config };
45
+ this.store = createStore(createInitialState);
46
+ }
47
+ // ═══════════════════════════════════════════════════════════════
48
+ // PUBLIC API
49
+ // ═══════════════════════════════════════════════════════════════
50
+ /**
51
+ * Load initial feed data
52
+ *
53
+ * Implements SWR pattern:
54
+ * 1. If cached data exists, show it immediately
55
+ * 2. If data is stale (>staleTime), revalidate in background
56
+ * 3. If no cached data, fetch from network
57
+ *
58
+ * Request Deduplication:
59
+ * - If a request for the same cursor is already in-flight, returns the existing Promise
60
+ * - Prevents duplicate API calls from rapid UI interactions
61
+ */
62
+ async loadInitial() {
63
+ const dedupeKey = "__initial__";
64
+ const existingRequest = this.inFlightRequests.get(dedupeKey);
65
+ if (existingRequest) {
66
+ return existingRequest;
67
+ }
68
+ const state = this.store.getState();
69
+ if (state.loading) {
70
+ return;
71
+ }
72
+ this.store.setState({ loading: true, error: null });
73
+ const request = this.executeLoadInitial();
74
+ this.inFlightRequests.set(dedupeKey, request);
75
+ try {
76
+ await request;
77
+ } finally {
78
+ this.inFlightRequests.delete(dedupeKey);
79
+ }
80
+ }
81
+ /**
82
+ * Internal: Execute load initial logic
83
+ */
84
+ async executeLoadInitial() {
85
+ try {
86
+ await this.fetchWithRetry();
87
+ } catch (error) {
88
+ this.handleError(error, "loadInitial");
89
+ } finally {
90
+ this.store.setState({ loading: false });
91
+ }
92
+ }
93
+ /**
94
+ * Load more videos (pagination)
95
+ *
96
+ * Features:
97
+ * - Race condition protection (prevents concurrent calls)
98
+ * - Request deduplication (prevents duplicate API calls for same cursor)
99
+ * - Exponential backoff retry on failure
100
+ */
101
+ async loadMore() {
102
+ const state = this.store.getState();
103
+ if (state.loading || state.loadingMore || !state.hasMore) {
104
+ return;
105
+ }
106
+ const cursor = state.cursor ?? "__null__";
107
+ const dedupeKey = `loadMore:${cursor}`;
108
+ const existingRequest = this.inFlightRequests.get(dedupeKey);
109
+ if (existingRequest) {
110
+ return existingRequest;
111
+ }
112
+ this.store.setState({ loadingMore: true, error: null });
113
+ const request = this.executeLoadMore(state.cursor ?? void 0);
114
+ this.inFlightRequests.set(dedupeKey, request);
115
+ try {
116
+ await request;
117
+ } finally {
118
+ this.inFlightRequests.delete(dedupeKey);
119
+ }
120
+ }
121
+ /**
122
+ * Internal: Execute load more logic
123
+ */
124
+ async executeLoadMore(cursor) {
125
+ try {
126
+ await this.fetchWithRetry(cursor);
127
+ } catch (error) {
128
+ this.handleError(error, "loadMore");
129
+ } finally {
130
+ this.store.setState({ loadingMore: false });
131
+ }
132
+ }
133
+ /**
134
+ * Revalidate feed data in background (SWR pattern)
135
+ *
136
+ * Used when cached data is stale but still shown to user
137
+ */
138
+ async revalidate() {
139
+ const state = this.store.getState();
140
+ if (state.loading || state.loadingMore) {
141
+ return;
142
+ }
143
+ try {
144
+ const response = await this.dataSource.fetchFeed();
145
+ this.mergeVideos(response.items, true);
146
+ this.store.setState({
147
+ cursor: response.nextCursor,
148
+ hasMore: response.hasMore,
149
+ isStale: false,
150
+ lastFetchTime: Date.now()
151
+ });
152
+ } catch {
153
+ }
154
+ }
155
+ /**
156
+ * Get a video by ID
157
+ * Also updates LRU access time for garbage collection
158
+ */
159
+ getVideo(id) {
160
+ const video = this.store.getState().itemsById.get(id);
161
+ if (video) {
162
+ this.accessOrder.set(id, Date.now());
163
+ }
164
+ return video;
165
+ }
166
+ /**
167
+ * Get ordered list of videos
168
+ */
169
+ getVideos() {
170
+ const state = this.store.getState();
171
+ return state.displayOrder.map((id) => state.itemsById.get(id)).filter((v) => v !== void 0);
172
+ }
173
+ /**
174
+ * Update a video in the feed (for optimistic updates)
175
+ */
176
+ updateVideo(id, updates) {
177
+ const state = this.store.getState();
178
+ const existing = state.itemsById.get(id);
179
+ if (existing) {
180
+ const newItemsById = new Map(state.itemsById);
181
+ newItemsById.set(id, { ...existing, ...updates });
182
+ this.store.setState({ itemsById: newItemsById });
183
+ }
184
+ }
185
+ /**
186
+ * Check if data is stale and needs revalidation
187
+ */
188
+ isStale() {
189
+ const state = this.store.getState();
190
+ if (!state.lastFetchTime) return true;
191
+ return Date.now() - state.lastFetchTime > this.config.staleTime;
192
+ }
193
+ /**
194
+ * Reset feed state
195
+ */
196
+ reset() {
197
+ this.cancelPendingRequests();
198
+ this.inFlightRequests.clear();
199
+ this.accessOrder.clear();
200
+ this.store.setState(createInitialState());
201
+ }
202
+ /**
203
+ * Cancel any pending requests
204
+ */
205
+ cancelPendingRequests() {
206
+ if (this.abortController) {
207
+ this.abortController.abort();
208
+ this.abortController = null;
209
+ }
210
+ }
211
+ /**
212
+ * Destroy the manager and cleanup
213
+ */
214
+ destroy() {
215
+ this.cancelPendingRequests();
216
+ this.inFlightRequests.clear();
217
+ this.accessOrder.clear();
218
+ this.store.setState(createInitialState());
219
+ }
220
+ // ═══════════════════════════════════════════════════════════════
221
+ // PRIVATE METHODS
222
+ // ═══════════════════════════════════════════════════════════════
223
+ /**
224
+ * Fetch with exponential backoff retry
225
+ */
226
+ async fetchWithRetry(cursor) {
227
+ let lastError = null;
228
+ for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
229
+ try {
230
+ this.abortController = new AbortController();
231
+ const response = await this.dataSource.fetchFeed(cursor);
232
+ this.addVideos(response.items);
233
+ this.store.setState({
234
+ cursor: response.nextCursor,
235
+ hasMore: response.hasMore,
236
+ isStale: false,
237
+ lastFetchTime: Date.now(),
238
+ error: null
239
+ });
240
+ return;
241
+ } catch (error) {
242
+ lastError = error instanceof Error ? error : new Error("Unknown error");
243
+ if (lastError.name === "AbortError") {
244
+ throw lastError;
245
+ }
246
+ if (attempt < this.config.maxRetries) {
247
+ const delay = this.config.retryBaseDelay * 2 ** attempt;
248
+ await this.sleep(delay);
249
+ }
250
+ }
251
+ }
252
+ throw lastError;
253
+ }
254
+ /**
255
+ * Add videos with deduplication
256
+ * Triggers garbage collection if cache exceeds maxCacheSize
257
+ */
258
+ addVideos(videos) {
259
+ const state = this.store.getState();
260
+ const newItemsById = new Map(state.itemsById);
261
+ const newDisplayOrder = [...state.displayOrder];
262
+ const now = Date.now();
263
+ for (const video of videos) {
264
+ if (!newItemsById.has(video.id)) {
265
+ newItemsById.set(video.id, video);
266
+ newDisplayOrder.push(video.id);
267
+ this.accessOrder.set(video.id, now);
268
+ }
269
+ }
270
+ this.store.setState({
271
+ itemsById: newItemsById,
272
+ displayOrder: newDisplayOrder
273
+ });
274
+ if (this.config.enableGC) {
275
+ this.runGarbageCollection();
276
+ }
277
+ }
278
+ /**
279
+ * Merge videos (for SWR revalidation)
280
+ * Updates existing videos, adds new ones at the beginning
281
+ */
282
+ mergeVideos(videos, prepend) {
283
+ const state = this.store.getState();
284
+ const newItemsById = new Map(state.itemsById);
285
+ const newIds = [];
286
+ for (const video of videos) {
287
+ if (newItemsById.has(video.id)) {
288
+ newItemsById.set(video.id, video);
289
+ } else {
290
+ newItemsById.set(video.id, video);
291
+ newIds.push(video.id);
292
+ }
293
+ }
294
+ const newDisplayOrder = prepend ? [...newIds, ...state.displayOrder] : [...state.displayOrder, ...newIds];
295
+ this.store.setState({
296
+ itemsById: newItemsById,
297
+ displayOrder: newDisplayOrder
298
+ });
299
+ }
300
+ /**
301
+ * Handle and categorize errors
302
+ */
303
+ handleError(error, _context) {
304
+ const feedError = this.categorizeError(error);
305
+ this.store.setState({ error: feedError });
306
+ }
307
+ /**
308
+ * Categorize error for better error handling
309
+ */
310
+ categorizeError(error) {
311
+ if (error instanceof Error) {
312
+ const lowerMessage = error.message.toLowerCase();
313
+ if (error.name === "TypeError" || lowerMessage.includes("network")) {
314
+ return {
315
+ message: "Network error. Please check your connection.",
316
+ code: "NETWORK_ERROR",
317
+ retryCount: this.config.maxRetries,
318
+ recoverable: true
319
+ };
320
+ }
321
+ if (error.name === "AbortError" || lowerMessage.includes("timeout")) {
322
+ return {
323
+ message: "Request timed out. Please try again.",
324
+ code: "TIMEOUT",
325
+ retryCount: this.config.maxRetries,
326
+ recoverable: true
327
+ };
328
+ }
329
+ if (lowerMessage.includes("500") || lowerMessage.includes("server")) {
330
+ return {
331
+ message: "Server error. Please try again later.",
332
+ code: "SERVER_ERROR",
333
+ retryCount: this.config.maxRetries,
334
+ recoverable: true
335
+ };
336
+ }
337
+ return {
338
+ message: error.message,
339
+ code: "UNKNOWN",
340
+ retryCount: this.config.maxRetries,
341
+ recoverable: false
342
+ };
343
+ }
344
+ return {
345
+ message: "An unexpected error occurred.",
346
+ code: "UNKNOWN",
347
+ retryCount: this.config.maxRetries,
348
+ recoverable: false
349
+ };
350
+ }
351
+ /**
352
+ * Sleep utility for retry delays
353
+ */
354
+ sleep(ms) {
355
+ return new Promise((resolve) => setTimeout(resolve, ms));
356
+ }
357
+ // ═══════════════════════════════════════════════════════════════
358
+ // GARBAGE COLLECTION
359
+ // ═══════════════════════════════════════════════════════════════
360
+ /**
361
+ * Run garbage collection using LRU (Least Recently Used) policy
362
+ *
363
+ * When cache size exceeds maxCacheSize:
364
+ * 1. Sort videos by last access time (oldest first)
365
+ * 2. Evict oldest videos until cache is within limit
366
+ * 3. Keep videos that are currently in viewport (most recent in displayOrder)
367
+ *
368
+ * @returns Number of evicted items
369
+ */
370
+ runGarbageCollection() {
371
+ const state = this.store.getState();
372
+ const currentSize = state.itemsById.size;
373
+ if (currentSize <= this.config.maxCacheSize) {
374
+ return 0;
375
+ }
376
+ const itemsToEvict = currentSize - this.config.maxCacheSize;
377
+ const sortedByAccess = Array.from(this.accessOrder.entries()).sort((a, b) => a[1] - b[1]).map(([id]) => id);
378
+ const minProtected = 3;
379
+ const protectedCount = Math.max(
380
+ minProtected,
381
+ Math.min(this.config.maxCacheSize, state.displayOrder.length)
382
+ );
383
+ const protectedIds = new Set(state.displayOrder.slice(-protectedCount));
384
+ const evictionCandidates = sortedByAccess.filter((id) => !protectedIds.has(id));
385
+ const actualEvictions = Math.min(itemsToEvict, evictionCandidates.length);
386
+ const idsToEvict = evictionCandidates.slice(0, actualEvictions);
387
+ if (idsToEvict.length === 0) {
388
+ return 0;
389
+ }
390
+ const newItemsById = new Map(state.itemsById);
391
+ const evictedSet = new Set(idsToEvict);
392
+ for (const id of idsToEvict) {
393
+ newItemsById.delete(id);
394
+ this.accessOrder.delete(id);
395
+ }
396
+ const newDisplayOrder = state.displayOrder.filter((id) => !evictedSet.has(id));
397
+ this.store.setState({
398
+ itemsById: newItemsById,
399
+ displayOrder: newDisplayOrder
400
+ });
401
+ return idsToEvict.length;
402
+ }
403
+ /**
404
+ * Manually trigger garbage collection
405
+ * Useful for debugging or when memory pressure is detected
406
+ *
407
+ * @returns Number of evicted items
408
+ */
409
+ forceGarbageCollection() {
410
+ return this.runGarbageCollection();
411
+ }
412
+ /**
413
+ * Get current cache statistics
414
+ * Useful for debugging and monitoring
415
+ */
416
+ getCacheStats() {
417
+ const state = this.store.getState();
418
+ const accessTimes = Array.from(this.accessOrder.values());
419
+ return {
420
+ size: state.itemsById.size,
421
+ maxSize: this.config.maxCacheSize,
422
+ utilizationPercent: Math.round(state.itemsById.size / this.config.maxCacheSize * 100),
423
+ oldestAccessTime: accessTimes.length > 0 ? Math.min(...accessTimes) : null,
424
+ newestAccessTime: accessTimes.length > 0 ? Math.max(...accessTimes) : null
425
+ };
426
+ }
427
+ };
428
+
429
+ // src/player/types.ts
430
+ var PlayerStatus = /* @__PURE__ */ ((PlayerStatus2) => {
431
+ PlayerStatus2["IDLE"] = "idle";
432
+ PlayerStatus2["LOADING"] = "loading";
433
+ PlayerStatus2["PLAYING"] = "playing";
434
+ PlayerStatus2["PAUSED"] = "paused";
435
+ PlayerStatus2["BUFFERING"] = "buffering";
436
+ PlayerStatus2["ERROR"] = "error";
437
+ return PlayerStatus2;
438
+ })(PlayerStatus || {});
439
+ var DEFAULT_PLAYER_CONFIG = {
440
+ autoplay: true,
441
+ defaultVolume: 1,
442
+ defaultMuted: true,
443
+ loop: true,
444
+ watchTimeThreshold: 3,
445
+ completionThreshold: 90
446
+ };
447
+ var DEFAULT_CIRCUIT_BREAKER_CONFIG = {
448
+ consecutiveErrorThreshold: 3,
449
+ resetTimeoutMs: 3e4,
450
+ // 30 seconds
451
+ successThreshold: 1,
452
+ enabled: true
453
+ };
454
+
455
+ // src/player/state-machine.ts
456
+ var VALID_TRANSITIONS = {
457
+ ["idle" /* IDLE */]: ["loading" /* LOADING */],
458
+ ["loading" /* LOADING */]: ["playing" /* PLAYING */, "error" /* ERROR */, "idle" /* IDLE */],
459
+ ["playing" /* PLAYING */]: [
460
+ "paused" /* PAUSED */,
461
+ "buffering" /* BUFFERING */,
462
+ "error" /* ERROR */,
463
+ "idle" /* IDLE */
464
+ ],
465
+ ["paused" /* PAUSED */]: ["playing" /* PLAYING */, "idle" /* IDLE */, "loading" /* LOADING */],
466
+ ["buffering" /* BUFFERING */]: ["playing" /* PLAYING */, "error" /* ERROR */, "idle" /* IDLE */],
467
+ ["error" /* ERROR */]: ["idle" /* IDLE */, "loading" /* LOADING */]
468
+ };
469
+ function isValidTransition(from, to) {
470
+ if (from === to) return true;
471
+ const validTargets = VALID_TRANSITIONS[from];
472
+ return validTargets.includes(to);
473
+ }
474
+ function getInvalidTransitionReason(from, to) {
475
+ const validTargets = VALID_TRANSITIONS[from];
476
+ return `Cannot transition from ${from} to ${to}. Valid transitions from ${from}: [${validTargets.join(", ")}]`;
477
+ }
478
+ function isActiveState(status) {
479
+ return status === "playing" /* PLAYING */ || status === "paused" /* PAUSED */ || status === "buffering" /* BUFFERING */;
480
+ }
481
+ function canPlay(status) {
482
+ return status === "paused" /* PAUSED */ || status === "loading" /* LOADING */;
483
+ }
484
+ function canPause(status) {
485
+ return status === "playing" /* PLAYING */ || status === "buffering" /* BUFFERING */;
486
+ }
487
+ function canSeek(status) {
488
+ return isActiveState(status);
489
+ }
490
+
491
+ // src/player/PlayerEngine.ts
492
+ var createInitialCircuitBreakerState = () => ({
493
+ state: "closed" /* CLOSED */,
494
+ consecutiveErrors: 0,
495
+ halfOpenSuccesses: 0,
496
+ openedAt: null,
497
+ lastError: null
498
+ });
499
+ var createInitialState2 = (config) => ({
500
+ status: "idle" /* IDLE */,
501
+ currentVideo: null,
502
+ currentTime: 0,
503
+ duration: 0,
504
+ buffered: 0,
505
+ volume: config.defaultVolume,
506
+ muted: config.defaultMuted,
507
+ playbackRate: 1,
508
+ loopCount: 0,
509
+ watchTime: 0,
510
+ error: null,
511
+ ended: false
512
+ });
513
+ var PlayerEngine = class {
514
+ constructor(config = {}) {
515
+ /** Event listeners */
516
+ this.eventListeners = /* @__PURE__ */ new Set();
517
+ /** Watch time tracking interval */
518
+ this.watchTimeInterval = null;
519
+ /** Last status for tracking transitions */
520
+ this.lastStatus = "idle" /* IDLE */;
521
+ /** Circuit Breaker state */
522
+ this.circuitBreaker = createInitialCircuitBreakerState();
523
+ /** Circuit Breaker reset timer */
524
+ this.circuitResetTimer = null;
525
+ const { analytics, logger, circuitBreaker, ...restConfig } = config;
526
+ this.config = { ...DEFAULT_PLAYER_CONFIG, ...restConfig };
527
+ this.circuitBreakerConfig = { ...DEFAULT_CIRCUIT_BREAKER_CONFIG, ...circuitBreaker };
528
+ this.analytics = analytics;
529
+ this.logger = logger;
530
+ this.store = createStore(() => createInitialState2(this.config));
531
+ this.store.subscribe((state) => {
532
+ if (state.status !== this.lastStatus) {
533
+ this.emitEvent({
534
+ type: "statusChange",
535
+ status: state.status,
536
+ previousStatus: this.lastStatus
537
+ });
538
+ this.lastStatus = state.status;
539
+ }
540
+ });
541
+ }
542
+ // ═══════════════════════════════════════════════════════════════
543
+ // PUBLIC API - PLAYBACK CONTROL
544
+ // ═══════════════════════════════════════════════════════════════
545
+ /**
546
+ * Load a video and prepare for playback
547
+ *
548
+ * @returns true if load was initiated, false if rejected (circuit open or invalid state)
549
+ */
550
+ load(video) {
551
+ if (!this.checkCircuitBreaker()) {
552
+ this.logger?.warn("[PlayerEngine] Load rejected - circuit breaker is OPEN");
553
+ this.emitEvent({ type: "loadRejected", reason: "circuit_open" });
554
+ if (this.circuitBreaker.lastError) {
555
+ this.store.setState({ error: this.circuitBreaker.lastError });
556
+ this.transitionTo("error" /* ERROR */);
557
+ }
558
+ return false;
559
+ }
560
+ const currentStatus = this.store.getState().status;
561
+ if (currentStatus !== "idle" /* IDLE */ && currentStatus !== "error" /* ERROR */ && currentStatus !== "paused" /* PAUSED */) {
562
+ if (!this.transitionTo("idle" /* IDLE */)) {
563
+ return false;
564
+ }
565
+ }
566
+ if (!this.transitionTo("loading" /* LOADING */)) {
567
+ return false;
568
+ }
569
+ this.store.setState({
570
+ currentVideo: video,
571
+ currentTime: 0,
572
+ duration: video.duration,
573
+ loopCount: 0,
574
+ watchTime: 0,
575
+ error: null,
576
+ ended: false
577
+ });
578
+ this.emitEvent({ type: "videoChange", video });
579
+ this.logger?.debug(`[PlayerEngine] Loaded video: ${video.id}`);
580
+ if (this.config.autoplay) {
581
+ this.onReady();
582
+ }
583
+ return true;
584
+ }
585
+ /**
586
+ * Called when video is ready to play (after loading)
587
+ */
588
+ onReady() {
589
+ const state = this.store.getState();
590
+ if (state.status !== "loading" /* LOADING */) {
591
+ this.logger?.warn(`[PlayerEngine] onReady called but status is ${state.status}, not LOADING`);
592
+ return false;
593
+ }
594
+ if (!this.transitionTo("playing" /* PLAYING */)) {
595
+ return false;
596
+ }
597
+ this.recordCircuitBreakerSuccess();
598
+ this.startWatchTimeTracking();
599
+ return true;
600
+ }
601
+ /**
602
+ * Start or resume playback
603
+ */
604
+ play() {
605
+ const state = this.store.getState();
606
+ if (!canPlay(state.status)) {
607
+ this.logger?.warn(`[PlayerEngine] Cannot play in status: ${state.status}`);
608
+ return false;
609
+ }
610
+ if (!this.transitionTo("playing" /* PLAYING */)) {
611
+ return false;
612
+ }
613
+ this.startWatchTimeTracking();
614
+ this.logger?.debug("[PlayerEngine] Playback started");
615
+ return true;
616
+ }
617
+ /**
618
+ * Pause playback
619
+ */
620
+ pause() {
621
+ const state = this.store.getState();
622
+ if (!canPause(state.status)) {
623
+ this.logger?.warn(`[PlayerEngine] Cannot pause in status: ${state.status}`);
624
+ return false;
625
+ }
626
+ if (!this.transitionTo("paused" /* PAUSED */)) {
627
+ return false;
628
+ }
629
+ this.stopWatchTimeTracking();
630
+ this.logger?.debug("[PlayerEngine] Playback paused");
631
+ return true;
632
+ }
633
+ /**
634
+ * Seek to a specific time
635
+ *
636
+ * Emits a 'seek' event that VideoPlayer listens to
637
+ * to apply the seek to the actual video element.
638
+ */
639
+ seek(time) {
640
+ const state = this.store.getState();
641
+ if (!canSeek(state.status)) {
642
+ this.logger?.warn(`[PlayerEngine] Cannot seek in status: ${state.status}`);
643
+ return false;
644
+ }
645
+ const clampedTime = Math.max(0, Math.min(time, state.duration));
646
+ this.store.setState({ currentTime: clampedTime, ended: false });
647
+ this.emitEvent({ type: "seek", time: clampedTime });
648
+ this.logger?.debug(`[PlayerEngine] Seeked to: ${clampedTime}s`);
649
+ return true;
650
+ }
651
+ /**
652
+ * Set volume
653
+ */
654
+ setVolume(volume) {
655
+ const clampedVolume = Math.max(0, Math.min(1, volume));
656
+ this.store.setState({ volume: clampedVolume });
657
+ }
658
+ /**
659
+ * Toggle mute
660
+ */
661
+ setMuted(muted) {
662
+ this.store.setState({ muted });
663
+ }
664
+ /**
665
+ * Set playback rate
666
+ */
667
+ setPlaybackRate(rate) {
668
+ const clampedRate = Math.max(0.25, Math.min(2, rate));
669
+ this.store.setState({ playbackRate: clampedRate });
670
+ }
671
+ // ═══════════════════════════════════════════════════════════════
672
+ // PUBLIC API - VIDEO ELEMENT EVENTS
673
+ // ═══════════════════════════════════════════════════════════════
674
+ /**
675
+ * Handle time update from video element
676
+ */
677
+ onTimeUpdate(currentTime) {
678
+ const state = this.store.getState();
679
+ this.store.setState({ currentTime });
680
+ this.emitEvent({
681
+ type: "timeUpdate",
682
+ currentTime,
683
+ duration: state.duration
684
+ });
685
+ }
686
+ /**
687
+ * Handle duration change from video element
688
+ *
689
+ * Called when video metadata is loaded and actual duration is available.
690
+ * This may differ from the API-provided duration in VideoItem.
691
+ *
692
+ * @param duration - Actual video duration in seconds from video element
693
+ */
694
+ onDurationChange(duration) {
695
+ if (duration > 0 && Number.isFinite(duration)) {
696
+ this.store.setState({ duration });
697
+ this.logger?.debug(`[PlayerEngine] Duration updated: ${duration}s`);
698
+ }
699
+ }
700
+ /**
701
+ * Handle buffering start
702
+ */
703
+ onBuffering() {
704
+ const state = this.store.getState();
705
+ if (state.status === "playing" /* PLAYING */) {
706
+ this.stopWatchTimeTracking();
707
+ return this.transitionTo("buffering" /* BUFFERING */);
708
+ }
709
+ return false;
710
+ }
711
+ /**
712
+ * Handle buffering end
713
+ */
714
+ onBufferingEnd() {
715
+ const state = this.store.getState();
716
+ if (state.status === "buffering" /* BUFFERING */) {
717
+ this.startWatchTimeTracking();
718
+ return this.transitionTo("playing" /* PLAYING */);
719
+ }
720
+ return false;
721
+ }
722
+ /**
723
+ * Handle buffer progress update
724
+ */
725
+ onBufferProgress(bufferedPercent) {
726
+ this.store.setState({ buffered: Math.min(100, Math.max(0, bufferedPercent)) });
727
+ }
728
+ /**
729
+ * Handle video ended
730
+ */
731
+ onEnded() {
732
+ const state = this.store.getState();
733
+ const newLoopCount = state.loopCount + 1;
734
+ this.store.setState({
735
+ loopCount: newLoopCount,
736
+ ended: true
737
+ });
738
+ this.emitEvent({
739
+ type: "ended",
740
+ loopCount: newLoopCount,
741
+ watchTime: state.watchTime
742
+ });
743
+ this.trackCompletion(state, newLoopCount);
744
+ if (this.config.loop) {
745
+ this.store.setState({ currentTime: 0, ended: false });
746
+ } else {
747
+ this.stopWatchTimeTracking();
748
+ this.transitionTo("paused" /* PAUSED */);
749
+ }
750
+ this.logger?.debug("[PlayerEngine] Video ended", {
751
+ loopCount: newLoopCount,
752
+ watchTime: state.watchTime
753
+ });
754
+ }
755
+ /**
756
+ * Handle video error
757
+ */
758
+ onError(error, mediaError) {
759
+ this.stopWatchTimeTracking();
760
+ const playerError = this.categorizeError(error, mediaError);
761
+ this.store.setState({ error: playerError });
762
+ this.transitionTo("error" /* ERROR */);
763
+ if (playerError.recoverable) {
764
+ this.recordCircuitBreakerError(playerError);
765
+ }
766
+ this.emitEvent({ type: "error", error: playerError });
767
+ this.logger?.error(`[PlayerEngine] Video error: ${playerError.message}`, error);
768
+ }
769
+ // ═══════════════════════════════════════════════════════════════
770
+ // PUBLIC API - LIFECYCLE
771
+ // ═══════════════════════════════════════════════════════════════
772
+ /**
773
+ * Reset player to initial state
774
+ */
775
+ reset() {
776
+ this.stopWatchTimeTracking();
777
+ this.store.setState(createInitialState2(this.config));
778
+ this.lastStatus = "idle" /* IDLE */;
779
+ this.emitEvent({ type: "videoChange", video: null });
780
+ this.logger?.debug("[PlayerEngine] Player reset");
781
+ }
782
+ /**
783
+ * Destroy player and cleanup
784
+ */
785
+ destroy() {
786
+ this.stopWatchTimeTracking();
787
+ this.clearCircuitResetTimer();
788
+ this.eventListeners.clear();
789
+ this.store.setState(createInitialState2(this.config));
790
+ this.circuitBreaker = createInitialCircuitBreakerState();
791
+ this.logger?.debug("[PlayerEngine] Player destroyed");
792
+ }
793
+ // ═══════════════════════════════════════════════════════════════
794
+ // PUBLIC API - EVENTS
795
+ // ═══════════════════════════════════════════════════════════════
796
+ /**
797
+ * Add event listener
798
+ */
799
+ addEventListener(listener) {
800
+ this.eventListeners.add(listener);
801
+ return () => this.eventListeners.delete(listener);
802
+ }
803
+ /**
804
+ * Remove event listener
805
+ */
806
+ removeEventListener(listener) {
807
+ this.eventListeners.delete(listener);
808
+ }
809
+ // ═══════════════════════════════════════════════════════════════
810
+ // PUBLIC API - GETTERS
811
+ // ═══════════════════════════════════════════════════════════════
812
+ /**
813
+ * Get current status
814
+ */
815
+ getStatus() {
816
+ return this.store.getState().status;
817
+ }
818
+ /**
819
+ * Get current video
820
+ */
821
+ getCurrentVideo() {
822
+ return this.store.getState().currentVideo;
823
+ }
824
+ /**
825
+ * Check if playing
826
+ */
827
+ isPlaying() {
828
+ return this.store.getState().status === "playing" /* PLAYING */;
829
+ }
830
+ /**
831
+ * Check if paused
832
+ */
833
+ isPaused() {
834
+ return this.store.getState().status === "paused" /* PAUSED */;
835
+ }
836
+ // ═══════════════════════════════════════════════════════════════
837
+ // PRIVATE METHODS
838
+ // ═══════════════════════════════════════════════════════════════
839
+ /**
840
+ * Attempt to transition to a new state
841
+ */
842
+ transitionTo(newStatus) {
843
+ const currentStatus = this.store.getState().status;
844
+ if (!isValidTransition(currentStatus, newStatus)) {
845
+ const reason = getInvalidTransitionReason(currentStatus, newStatus);
846
+ this.logger?.warn(`[PlayerEngine] ${reason}`);
847
+ return false;
848
+ }
849
+ this.store.setState({ status: newStatus });
850
+ return true;
851
+ }
852
+ /**
853
+ * Emit event to all listeners
854
+ */
855
+ emitEvent(event) {
856
+ for (const listener of this.eventListeners) {
857
+ try {
858
+ listener(event);
859
+ } catch (err) {
860
+ this.logger?.error(
861
+ "[PlayerEngine] Event listener error",
862
+ err instanceof Error ? err : new Error(String(err))
863
+ );
864
+ }
865
+ }
866
+ }
867
+ /**
868
+ * Start watch time tracking
869
+ */
870
+ startWatchTimeTracking() {
871
+ if (this.watchTimeInterval) return;
872
+ this.watchTimeInterval = setInterval(() => {
873
+ const state = this.store.getState();
874
+ if (state.status === "playing" /* PLAYING */) {
875
+ this.store.setState({ watchTime: state.watchTime + 1 });
876
+ }
877
+ }, 1e3);
878
+ }
879
+ /**
880
+ * Stop watch time tracking
881
+ */
882
+ stopWatchTimeTracking() {
883
+ if (this.watchTimeInterval) {
884
+ clearInterval(this.watchTimeInterval);
885
+ this.watchTimeInterval = null;
886
+ }
887
+ }
888
+ /**
889
+ * Track completion analytics
890
+ */
891
+ trackCompletion(state, loopCount) {
892
+ if (!this.analytics || !state.currentVideo) return;
893
+ const watchPercentage = state.duration > 0 ? state.watchTime / state.duration * 100 : 0;
894
+ if (state.watchTime >= this.config.watchTimeThreshold) {
895
+ this.analytics.trackViewDuration(state.currentVideo.id, state.watchTime, state.duration);
896
+ }
897
+ if (watchPercentage >= this.config.completionThreshold) {
898
+ this.analytics.trackCompletion(state.currentVideo.id, state.watchTime, loopCount);
899
+ }
900
+ }
901
+ /**
902
+ * Categorize media error
903
+ */
904
+ categorizeError(error, mediaError) {
905
+ if (mediaError) {
906
+ switch (mediaError.code) {
907
+ case MediaError.MEDIA_ERR_ABORTED:
908
+ return {
909
+ message: "Video playback was aborted.",
910
+ code: "MEDIA_ERROR",
911
+ recoverable: true,
912
+ originalError: error
913
+ };
914
+ case MediaError.MEDIA_ERR_NETWORK:
915
+ return {
916
+ message: "Network error while loading video.",
917
+ code: "NETWORK_ERROR",
918
+ recoverable: true,
919
+ originalError: error
920
+ };
921
+ case MediaError.MEDIA_ERR_DECODE:
922
+ return {
923
+ message: "Video decoding error.",
924
+ code: "DECODE_ERROR",
925
+ recoverable: false,
926
+ originalError: error
927
+ };
928
+ case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
929
+ return {
930
+ message: "Video format not supported.",
931
+ code: "NOT_SUPPORTED",
932
+ recoverable: false,
933
+ originalError: error
934
+ };
935
+ }
936
+ }
937
+ const lowerMessage = error.message.toLowerCase();
938
+ if (lowerMessage.includes("network")) {
939
+ return {
940
+ message: "Network error occurred.",
941
+ code: "NETWORK_ERROR",
942
+ recoverable: true,
943
+ originalError: error
944
+ };
945
+ }
946
+ return {
947
+ message: error.message || "An unknown error occurred.",
948
+ code: "UNKNOWN",
949
+ recoverable: false,
950
+ originalError: error
951
+ };
952
+ }
953
+ // ═══════════════════════════════════════════════════════════════
954
+ // PUBLIC API - CIRCUIT BREAKER
955
+ // ═══════════════════════════════════════════════════════════════
956
+ /**
957
+ * Get current circuit breaker state
958
+ */
959
+ getCircuitBreakerState() {
960
+ return { ...this.circuitBreaker };
961
+ }
962
+ /**
963
+ * Check if circuit breaker is open (blocking loads)
964
+ */
965
+ isCircuitOpen() {
966
+ return this.circuitBreaker.state === "open" /* OPEN */;
967
+ }
968
+ /**
969
+ * Manually reset circuit breaker to CLOSED state
970
+ * Useful for user-triggered retry after showing error UI
971
+ */
972
+ resetCircuitBreaker() {
973
+ this.clearCircuitResetTimer();
974
+ this.circuitBreaker = createInitialCircuitBreakerState();
975
+ this.emitEvent({ type: "circuitClosed", reason: "manual" });
976
+ this.logger?.debug("[PlayerEngine] Circuit breaker manually reset");
977
+ }
978
+ // ═══════════════════════════════════════════════════════════════
979
+ // PRIVATE METHODS - CIRCUIT BREAKER
980
+ // ═══════════════════════════════════════════════════════════════
981
+ /**
982
+ * Check if circuit breaker allows load operation
983
+ * Also handles OPEN → HALF_OPEN transition based on timeout
984
+ */
985
+ checkCircuitBreaker() {
986
+ if (!this.circuitBreakerConfig.enabled) {
987
+ return true;
988
+ }
989
+ const { state, openedAt } = this.circuitBreaker;
990
+ const { resetTimeoutMs } = this.circuitBreakerConfig;
991
+ switch (state) {
992
+ case "closed" /* CLOSED */:
993
+ return true;
994
+ case "open" /* OPEN */:
995
+ if (openedAt && Date.now() - openedAt >= resetTimeoutMs) {
996
+ this.transitionToHalfOpen();
997
+ return true;
998
+ }
999
+ return false;
1000
+ // Still in OPEN state, reject
1001
+ case "half_open" /* HALF_OPEN */:
1002
+ return true;
1003
+ default:
1004
+ return true;
1005
+ }
1006
+ }
1007
+ /**
1008
+ * Record a recoverable error for circuit breaker tracking
1009
+ */
1010
+ recordCircuitBreakerError(error) {
1011
+ if (!this.circuitBreakerConfig.enabled) {
1012
+ return;
1013
+ }
1014
+ const { consecutiveErrorThreshold } = this.circuitBreakerConfig;
1015
+ const newConsecutiveErrors = this.circuitBreaker.consecutiveErrors + 1;
1016
+ this.circuitBreaker.consecutiveErrors = newConsecutiveErrors;
1017
+ this.circuitBreaker.lastError = error;
1018
+ this.logger?.debug("[PlayerEngine] Circuit breaker recorded error", {
1019
+ consecutiveErrors: newConsecutiveErrors,
1020
+ threshold: consecutiveErrorThreshold
1021
+ });
1022
+ if (newConsecutiveErrors >= consecutiveErrorThreshold) {
1023
+ this.tripCircuit(error);
1024
+ }
1025
+ if (this.circuitBreaker.state === "half_open" /* HALF_OPEN */) {
1026
+ this.tripCircuit(error);
1027
+ }
1028
+ }
1029
+ /**
1030
+ * Record a successful load for circuit breaker tracking
1031
+ */
1032
+ recordCircuitBreakerSuccess() {
1033
+ if (!this.circuitBreakerConfig.enabled) {
1034
+ return;
1035
+ }
1036
+ const { state } = this.circuitBreaker;
1037
+ const { successThreshold } = this.circuitBreakerConfig;
1038
+ this.circuitBreaker.consecutiveErrors = 0;
1039
+ if (state === "half_open" /* HALF_OPEN */) {
1040
+ this.circuitBreaker.halfOpenSuccesses += 1;
1041
+ this.logger?.debug("[PlayerEngine] Circuit breaker half-open success", {
1042
+ halfOpenSuccesses: this.circuitBreaker.halfOpenSuccesses,
1043
+ threshold: successThreshold
1044
+ });
1045
+ if (this.circuitBreaker.halfOpenSuccesses >= successThreshold) {
1046
+ this.closeCircuit();
1047
+ }
1048
+ }
1049
+ }
1050
+ /**
1051
+ * Trip the circuit breaker (CLOSED/HALF_OPEN → OPEN)
1052
+ */
1053
+ tripCircuit(error) {
1054
+ this.clearCircuitResetTimer();
1055
+ this.circuitBreaker.state = "open" /* OPEN */;
1056
+ this.circuitBreaker.openedAt = Date.now();
1057
+ this.circuitBreaker.halfOpenSuccesses = 0;
1058
+ this.emitEvent({
1059
+ type: "circuitOpened",
1060
+ consecutiveErrors: this.circuitBreaker.consecutiveErrors,
1061
+ lastError: error
1062
+ });
1063
+ this.logger?.warn("[PlayerEngine] Circuit breaker OPENED", {
1064
+ consecutiveErrors: this.circuitBreaker.consecutiveErrors,
1065
+ error: error.message
1066
+ });
1067
+ this.scheduleHalfOpenTransition();
1068
+ }
1069
+ /**
1070
+ * Transition from OPEN to HALF_OPEN
1071
+ */
1072
+ transitionToHalfOpen() {
1073
+ this.circuitBreaker.state = "half_open" /* HALF_OPEN */;
1074
+ this.circuitBreaker.halfOpenSuccesses = 0;
1075
+ this.emitEvent({ type: "circuitHalfOpen" });
1076
+ this.logger?.debug("[PlayerEngine] Circuit breaker transitioned to HALF_OPEN");
1077
+ }
1078
+ /**
1079
+ * Close the circuit breaker (HALF_OPEN → CLOSED)
1080
+ */
1081
+ closeCircuit() {
1082
+ this.clearCircuitResetTimer();
1083
+ this.circuitBreaker = createInitialCircuitBreakerState();
1084
+ this.emitEvent({ type: "circuitClosed", reason: "success" });
1085
+ this.logger?.debug("[PlayerEngine] Circuit breaker CLOSED after successful recovery");
1086
+ }
1087
+ /**
1088
+ * Schedule automatic transition from OPEN to HALF_OPEN
1089
+ */
1090
+ scheduleHalfOpenTransition() {
1091
+ this.clearCircuitResetTimer();
1092
+ this.circuitResetTimer = setTimeout(() => {
1093
+ if (this.circuitBreaker.state === "open" /* OPEN */) {
1094
+ this.transitionToHalfOpen();
1095
+ }
1096
+ }, this.circuitBreakerConfig.resetTimeoutMs);
1097
+ }
1098
+ /**
1099
+ * Clear circuit reset timer
1100
+ */
1101
+ clearCircuitResetTimer() {
1102
+ if (this.circuitResetTimer) {
1103
+ clearTimeout(this.circuitResetTimer);
1104
+ this.circuitResetTimer = null;
1105
+ }
1106
+ }
1107
+ };
1108
+
1109
+ // src/lifecycle/types.ts
1110
+ var DEFAULT_LIFECYCLE_CONFIG = {
1111
+ snapshotExpiryMs: 24 * 60 * 60 * 1e3,
1112
+ // 24 hours
1113
+ revalidationThresholdMs: 5 * 60 * 1e3,
1114
+ // 5 minutes
1115
+ autoSaveOnHidden: true,
1116
+ restorePlaybackPosition: false,
1117
+ // User must opt-in for playback position restore
1118
+ version: "1.0.0"
1119
+ };
1120
+
1121
+ // src/lifecycle/LifecycleManager.ts
1122
+ var createInitialState3 = () => ({
1123
+ isInitialized: false,
1124
+ isSaving: false,
1125
+ isRestoring: false,
1126
+ lastSavedAt: null,
1127
+ lastRestoredAt: null,
1128
+ needsRevalidation: false,
1129
+ visibilityState: typeof document !== "undefined" ? document.visibilityState : "visible"
1130
+ });
1131
+ var LifecycleManager = class {
1132
+ constructor(config = {}) {
1133
+ /** Event listeners */
1134
+ this.eventListeners = /* @__PURE__ */ new Set();
1135
+ const { storage, logger, ...restConfig } = config;
1136
+ this.config = { ...DEFAULT_LIFECYCLE_CONFIG, ...restConfig };
1137
+ this.storage = storage;
1138
+ this.logger = logger;
1139
+ this.store = createStore(createInitialState3);
1140
+ }
1141
+ // ═══════════════════════════════════════════════════════════════
1142
+ // PUBLIC API - LIFECYCLE
1143
+ // ═══════════════════════════════════════════════════════════════
1144
+ /**
1145
+ * Initialize lifecycle manager and attempt to restore session
1146
+ */
1147
+ async initialize() {
1148
+ if (this.store.getState().isInitialized) {
1149
+ this.logger?.warn("[LifecycleManager] Already initialized");
1150
+ return {
1151
+ success: false,
1152
+ snapshot: null,
1153
+ needsRevalidation: false,
1154
+ reason: "error"
1155
+ };
1156
+ }
1157
+ if (this.config.autoSaveOnHidden && typeof document !== "undefined") {
1158
+ this.setupVisibilityListener();
1159
+ }
1160
+ this.store.setState({ isInitialized: true });
1161
+ this.logger?.debug("[LifecycleManager] Initialized");
1162
+ return this.restoreSession();
1163
+ }
1164
+ /**
1165
+ * Destroy lifecycle manager and cleanup
1166
+ */
1167
+ destroy() {
1168
+ if (this.visibilityHandler && typeof document !== "undefined") {
1169
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
1170
+ this.visibilityHandler = void 0;
1171
+ }
1172
+ this.eventListeners.clear();
1173
+ this.store.setState(createInitialState3());
1174
+ this.logger?.debug("[LifecycleManager] Destroyed");
1175
+ }
1176
+ // ═══════════════════════════════════════════════════════════════
1177
+ // PUBLIC API - SESSION MANAGEMENT
1178
+ // ═══════════════════════════════════════════════════════════════
1179
+ /**
1180
+ * Restore session from storage
1181
+ */
1182
+ async restoreSession() {
1183
+ if (!this.storage) {
1184
+ this.logger?.debug("[LifecycleManager] No storage adapter, skipping restore");
1185
+ return {
1186
+ success: false,
1187
+ snapshot: null,
1188
+ needsRevalidation: false,
1189
+ reason: "no_snapshot"
1190
+ };
1191
+ }
1192
+ this.store.setState({ isRestoring: true });
1193
+ this.emitEvent({ type: "restoreStart" });
1194
+ try {
1195
+ const snapshot = await this.storage.loadSnapshot();
1196
+ if (!snapshot) {
1197
+ this.logger?.debug("[LifecycleManager] No snapshot found");
1198
+ const result2 = {
1199
+ success: false,
1200
+ snapshot: null,
1201
+ needsRevalidation: false,
1202
+ reason: "no_snapshot"
1203
+ };
1204
+ this.store.setState({ isRestoring: false });
1205
+ this.emitEvent({ type: "restoreComplete", result: result2 });
1206
+ return result2;
1207
+ }
1208
+ const validationResult = this.validateSnapshot(snapshot);
1209
+ if (!validationResult.valid) {
1210
+ this.logger?.debug(`[LifecycleManager] Invalid snapshot: ${validationResult.reason}`);
1211
+ await this.clearSnapshot();
1212
+ const result2 = {
1213
+ success: false,
1214
+ snapshot: null,
1215
+ needsRevalidation: false,
1216
+ reason: validationResult.reason
1217
+ };
1218
+ this.store.setState({ isRestoring: false });
1219
+ this.emitEvent({ type: "restoreComplete", result: result2 });
1220
+ return result2;
1221
+ }
1222
+ const needsRevalidation = this.isSnapshotStale(snapshot);
1223
+ const validSnapshot = snapshot;
1224
+ const result = {
1225
+ success: true,
1226
+ snapshot: validSnapshot,
1227
+ needsRevalidation
1228
+ };
1229
+ if (this.config.restorePlaybackPosition) {
1230
+ if (validSnapshot.playbackTime !== void 0) {
1231
+ result.playbackTime = validSnapshot.playbackTime;
1232
+ }
1233
+ if (validSnapshot.currentVideoId !== void 0) {
1234
+ result.currentVideoId = validSnapshot.currentVideoId;
1235
+ }
1236
+ }
1237
+ this.store.setState({
1238
+ isRestoring: false,
1239
+ lastRestoredAt: Date.now(),
1240
+ needsRevalidation
1241
+ });
1242
+ this.emitEvent({ type: "restoreComplete", result });
1243
+ this.logger?.debug("[LifecycleManager] Session restored", {
1244
+ itemCount: validSnapshot.items.length,
1245
+ needsRevalidation,
1246
+ hasPlaybackTime: this.config.restorePlaybackPosition && validSnapshot.playbackTime !== void 0
1247
+ });
1248
+ return result;
1249
+ } catch (error) {
1250
+ this.logger?.error(
1251
+ "[LifecycleManager] Restore failed",
1252
+ error instanceof Error ? error : new Error(String(error))
1253
+ );
1254
+ const result = {
1255
+ success: false,
1256
+ snapshot: null,
1257
+ needsRevalidation: false,
1258
+ reason: "error"
1259
+ };
1260
+ this.store.setState({ isRestoring: false });
1261
+ this.emitEvent({ type: "restoreComplete", result });
1262
+ return result;
1263
+ }
1264
+ }
1265
+ /**
1266
+ * Save session snapshot to storage
1267
+ *
1268
+ * @param data - Snapshot data to save
1269
+ * @param data.playbackTime - Current video playback position (only saved if restorePlaybackPosition config is enabled)
1270
+ * @param data.currentVideoId - Current video ID (only saved if restorePlaybackPosition config is enabled)
1271
+ */
1272
+ async saveSnapshot(data) {
1273
+ if (!this.storage) {
1274
+ this.logger?.debug("[LifecycleManager] No storage adapter, skipping save");
1275
+ return false;
1276
+ }
1277
+ if (data.items.length === 0) {
1278
+ this.logger?.debug("[LifecycleManager] No items to save, skipping");
1279
+ return false;
1280
+ }
1281
+ this.store.setState({ isSaving: true });
1282
+ this.emitEvent({ type: "saveStart" });
1283
+ try {
1284
+ const snapshot = {
1285
+ items: data.items,
1286
+ cursor: data.cursor,
1287
+ focusedIndex: data.focusedIndex,
1288
+ scrollPosition: data.scrollPosition,
1289
+ savedAt: Date.now(),
1290
+ version: this.config.version
1291
+ };
1292
+ if (this.config.restorePlaybackPosition) {
1293
+ if (data.playbackTime !== void 0) {
1294
+ snapshot.playbackTime = data.playbackTime;
1295
+ }
1296
+ if (data.currentVideoId !== void 0) {
1297
+ snapshot.currentVideoId = data.currentVideoId;
1298
+ }
1299
+ }
1300
+ await this.storage.saveSnapshot(snapshot);
1301
+ const timestamp = Date.now();
1302
+ this.store.setState({
1303
+ isSaving: false,
1304
+ lastSavedAt: timestamp
1305
+ });
1306
+ this.emitEvent({ type: "saveComplete", timestamp });
1307
+ this.logger?.debug("[LifecycleManager] Snapshot saved", {
1308
+ itemCount: data.items.length,
1309
+ hasPlaybackTime: this.config.restorePlaybackPosition && data.playbackTime !== void 0
1310
+ });
1311
+ return true;
1312
+ } catch (error) {
1313
+ this.logger?.error(
1314
+ "[LifecycleManager] Save failed",
1315
+ error instanceof Error ? error : new Error(String(error))
1316
+ );
1317
+ this.store.setState({ isSaving: false });
1318
+ this.emitEvent({
1319
+ type: "saveFailed",
1320
+ error: error instanceof Error ? error : new Error(String(error))
1321
+ });
1322
+ return false;
1323
+ }
1324
+ }
1325
+ /**
1326
+ * Clear saved snapshot
1327
+ */
1328
+ async clearSnapshot() {
1329
+ if (!this.storage) return;
1330
+ try {
1331
+ await this.storage.clearSnapshot();
1332
+ this.logger?.debug("[LifecycleManager] Snapshot cleared");
1333
+ } catch (error) {
1334
+ this.logger?.error(
1335
+ "[LifecycleManager] Clear failed",
1336
+ error instanceof Error ? error : new Error(String(error))
1337
+ );
1338
+ }
1339
+ }
1340
+ /**
1341
+ * Mark that pending data should be saved (for use with debouncing)
1342
+ *
1343
+ * @param data - Data to save when flush is called
1344
+ */
1345
+ setPendingSave(data) {
1346
+ this.pendingSaveData = data;
1347
+ }
1348
+ /**
1349
+ * Check if restorePlaybackPosition config is enabled
1350
+ * SDK can use this to decide whether to collect playbackTime
1351
+ */
1352
+ isPlaybackPositionRestoreEnabled() {
1353
+ return this.config.restorePlaybackPosition;
1354
+ }
1355
+ /**
1356
+ * Flush pending save (called on visibility hidden)
1357
+ */
1358
+ async flushPendingSave() {
1359
+ if (this.pendingSaveData) {
1360
+ await this.saveSnapshot(this.pendingSaveData);
1361
+ this.pendingSaveData = void 0;
1362
+ }
1363
+ }
1364
+ // ═══════════════════════════════════════════════════════════════
1365
+ // PUBLIC API - VISIBILITY
1366
+ // ═══════════════════════════════════════════════════════════════
1367
+ /**
1368
+ * Handle visibility state change
1369
+ */
1370
+ onVisibilityChange(state) {
1371
+ const previousState = this.store.getState().visibilityState;
1372
+ if (state === previousState) return;
1373
+ this.store.setState({ visibilityState: state });
1374
+ this.emitEvent({ type: "visibilityChange", state });
1375
+ if (state === "hidden" && this.config.autoSaveOnHidden) {
1376
+ this.flushPendingSave();
1377
+ }
1378
+ this.logger?.debug("[LifecycleManager] Visibility changed", { state });
1379
+ }
1380
+ /**
1381
+ * Get current visibility state
1382
+ */
1383
+ getVisibilityState() {
1384
+ return this.store.getState().visibilityState;
1385
+ }
1386
+ // ═══════════════════════════════════════════════════════════════
1387
+ // PUBLIC API - EVENTS
1388
+ // ═══════════════════════════════════════════════════════════════
1389
+ /**
1390
+ * Add event listener
1391
+ */
1392
+ addEventListener(listener) {
1393
+ this.eventListeners.add(listener);
1394
+ return () => this.eventListeners.delete(listener);
1395
+ }
1396
+ /**
1397
+ * Remove event listener
1398
+ */
1399
+ removeEventListener(listener) {
1400
+ this.eventListeners.delete(listener);
1401
+ }
1402
+ // ═══════════════════════════════════════════════════════════════
1403
+ // PRIVATE METHODS
1404
+ // ═══════════════════════════════════════════════════════════════
1405
+ /**
1406
+ * Setup visibility change listener
1407
+ */
1408
+ setupVisibilityListener() {
1409
+ this.visibilityHandler = () => {
1410
+ this.onVisibilityChange(document.visibilityState);
1411
+ };
1412
+ document.addEventListener("visibilitychange", this.visibilityHandler);
1413
+ }
1414
+ /**
1415
+ * Validate snapshot data
1416
+ */
1417
+ validateSnapshot(snapshot) {
1418
+ if (!snapshot || typeof snapshot !== "object") {
1419
+ return { valid: false, reason: "invalid" };
1420
+ }
1421
+ const s = snapshot;
1422
+ if (!Array.isArray(s.items)) {
1423
+ return { valid: false, reason: "invalid" };
1424
+ }
1425
+ if (typeof s.savedAt !== "number") {
1426
+ return { valid: false, reason: "invalid" };
1427
+ }
1428
+ if (Date.now() - s.savedAt > this.config.snapshotExpiryMs) {
1429
+ return { valid: false, reason: "expired" };
1430
+ }
1431
+ return { valid: true };
1432
+ }
1433
+ /**
1434
+ * Check if snapshot is stale (needs revalidation)
1435
+ */
1436
+ isSnapshotStale(snapshot) {
1437
+ return Date.now() - snapshot.savedAt > this.config.revalidationThresholdMs;
1438
+ }
1439
+ /**
1440
+ * Emit event to all listeners
1441
+ */
1442
+ emitEvent(event) {
1443
+ for (const listener of this.eventListeners) {
1444
+ try {
1445
+ listener(event);
1446
+ } catch (err) {
1447
+ this.logger?.error(
1448
+ "[LifecycleManager] Event listener error",
1449
+ err instanceof Error ? err : new Error(String(err))
1450
+ );
1451
+ }
1452
+ }
1453
+ }
1454
+ };
1455
+
1456
+ // src/resource/allocation.ts
1457
+ function calculateWindowIndices(focusedIndex, totalItems, maxAllocations = 3) {
1458
+ if (totalItems === 0) return [];
1459
+ const indices = [];
1460
+ const clampedFocus = Math.max(0, Math.min(focusedIndex, totalItems - 1));
1461
+ indices.push(clampedFocus);
1462
+ if (clampedFocus > 0 && indices.length < maxAllocations) {
1463
+ indices.push(clampedFocus - 1);
1464
+ }
1465
+ if (clampedFocus < totalItems - 1 && indices.length < maxAllocations) {
1466
+ indices.push(clampedFocus + 1);
1467
+ }
1468
+ return indices.sort((a, b) => a - b);
1469
+ }
1470
+ function calculatePrefetchIndices(focusedIndex, totalItems, prefetchCount, windowIndices) {
1471
+ if (totalItems === 0 || prefetchCount === 0) return [];
1472
+ const windowSet = new Set(windowIndices);
1473
+ const prefetchIndices = [];
1474
+ for (let i = 1; i <= prefetchCount; i++) {
1475
+ const index = focusedIndex + i;
1476
+ if (index < totalItems && !windowSet.has(index)) {
1477
+ prefetchIndices.push(index);
1478
+ }
1479
+ }
1480
+ return prefetchIndices;
1481
+ }
1482
+ function computeAllocationChanges(currentAllocations, desiredAllocations) {
1483
+ const desiredSet = new Set(desiredAllocations);
1484
+ const toUnmount = [];
1485
+ for (const index of currentAllocations) {
1486
+ if (!desiredSet.has(index)) {
1487
+ toUnmount.push(index);
1488
+ }
1489
+ }
1490
+ const toMount = [];
1491
+ for (const index of desiredAllocations) {
1492
+ if (!currentAllocations.has(index)) {
1493
+ toMount.push(index);
1494
+ }
1495
+ }
1496
+ return {
1497
+ toMount: toMount.sort((a, b) => a - b),
1498
+ toUnmount: toUnmount.sort((a, b) => a - b),
1499
+ activeAllocations: desiredAllocations
1500
+ };
1501
+ }
1502
+
1503
+ // src/resource/types.ts
1504
+ function mapNetworkType(type) {
1505
+ switch (type) {
1506
+ case "wifi":
1507
+ return "wifi";
1508
+ case "cellular":
1509
+ case "4g":
1510
+ case "3g":
1511
+ case "slow":
1512
+ return "cellular";
1513
+ case "offline":
1514
+ return "offline";
1515
+ default:
1516
+ return "wifi";
1517
+ }
1518
+ }
1519
+ var DEFAULT_SCROLL_THRASHING_CONFIG = {
1520
+ windowMs: 1e3,
1521
+ maxChangesInWindow: 3,
1522
+ cooldownMs: 500
1523
+ };
1524
+ var DEFAULT_PREFETCH_CONFIG = {
1525
+ wifi: {
1526
+ posterCount: 5,
1527
+ videoSegmentCount: 2,
1528
+ prefetchVideo: true
1529
+ },
1530
+ cellular: {
1531
+ posterCount: 3,
1532
+ videoSegmentCount: 1,
1533
+ prefetchVideo: true
1534
+ },
1535
+ offline: {
1536
+ posterCount: 1,
1537
+ videoSegmentCount: 0,
1538
+ prefetchVideo: false
1539
+ }
1540
+ };
1541
+ var DEFAULT_RESOURCE_CONFIG = {
1542
+ maxAllocations: 3,
1543
+ focusDebounceMs: 150,
1544
+ prefetch: DEFAULT_PREFETCH_CONFIG,
1545
+ scrollThrashing: DEFAULT_SCROLL_THRASHING_CONFIG
1546
+ };
1547
+
1548
+ // src/resource/ResourceGovernor.ts
1549
+ var createInitialState4 = () => ({
1550
+ activeAllocations: /* @__PURE__ */ new Set(),
1551
+ preloadQueue: [],
1552
+ focusedIndex: 0,
1553
+ networkType: "wifi",
1554
+ totalItems: 0,
1555
+ isActive: false,
1556
+ isThrottled: false,
1557
+ preloadingIndices: /* @__PURE__ */ new Set()
1558
+ });
1559
+ var ResourceGovernor = class {
1560
+ constructor(config = {}) {
1561
+ /** Event listeners */
1562
+ this.eventListeners = /* @__PURE__ */ new Set();
1563
+ /** Focus debounce timer */
1564
+ this.focusDebounceTimer = null;
1565
+ /** Pending focused index (before debounce completes) */
1566
+ this.pendingFocusedIndex = null;
1567
+ /** Scroll thrashing detection - timestamps of recent focus changes */
1568
+ this.focusChangeTimestamps = [];
1569
+ /** Scroll thrashing cooldown timer */
1570
+ this.thrashingCooldownTimer = null;
1571
+ const {
1572
+ networkAdapter,
1573
+ videoLoader,
1574
+ posterLoader,
1575
+ logger,
1576
+ prefetch,
1577
+ scrollThrashing,
1578
+ ...restConfig
1579
+ } = config;
1580
+ this.config = {
1581
+ ...DEFAULT_RESOURCE_CONFIG,
1582
+ ...restConfig,
1583
+ prefetch: {
1584
+ wifi: { ...DEFAULT_PREFETCH_CONFIG.wifi, ...prefetch?.wifi },
1585
+ cellular: { ...DEFAULT_PREFETCH_CONFIG.cellular, ...prefetch?.cellular },
1586
+ offline: { ...DEFAULT_PREFETCH_CONFIG.offline, ...prefetch?.offline }
1587
+ },
1588
+ scrollThrashing: {
1589
+ ...DEFAULT_SCROLL_THRASHING_CONFIG,
1590
+ ...scrollThrashing
1591
+ }
1592
+ };
1593
+ this.networkAdapter = networkAdapter;
1594
+ this.videoLoader = videoLoader;
1595
+ this.posterLoader = posterLoader;
1596
+ this.logger = logger;
1597
+ this.store = createStore(createInitialState4);
1598
+ }
1599
+ // ═══════════════════════════════════════════════════════════════
1600
+ // PUBLIC API - LIFECYCLE
1601
+ // ═══════════════════════════════════════════════════════════════
1602
+ /**
1603
+ * Activate the resource governor
1604
+ * Starts network monitoring and performs initial allocation
1605
+ */
1606
+ async activate() {
1607
+ if (this.store.getState().isActive) return;
1608
+ this.store.setState({ isActive: true });
1609
+ await this.initializeNetwork();
1610
+ this.performAllocation(this.store.getState().focusedIndex);
1611
+ this.logger?.debug("[ResourceGovernor] Activated");
1612
+ }
1613
+ /**
1614
+ * Deactivate the resource governor
1615
+ * Stops network monitoring and clears allocations
1616
+ */
1617
+ deactivate() {
1618
+ if (!this.store.getState().isActive) return;
1619
+ if (this.focusDebounceTimer) {
1620
+ clearTimeout(this.focusDebounceTimer);
1621
+ this.focusDebounceTimer = null;
1622
+ }
1623
+ if (this.thrashingCooldownTimer) {
1624
+ clearTimeout(this.thrashingCooldownTimer);
1625
+ this.thrashingCooldownTimer = null;
1626
+ }
1627
+ this.focusChangeTimestamps = [];
1628
+ if (this.networkUnsubscribe) {
1629
+ this.networkUnsubscribe();
1630
+ this.networkUnsubscribe = void 0;
1631
+ }
1632
+ const state = this.store.getState();
1633
+ if (this.videoLoader) {
1634
+ for (const index of state.preloadingIndices) {
1635
+ const videoInfo = this.videoSourceGetter?.(index);
1636
+ if (videoInfo) {
1637
+ this.videoLoader.cancelPreload(videoInfo.id);
1638
+ }
1639
+ }
1640
+ }
1641
+ if (state.activeAllocations.size > 0) {
1642
+ this.emitEvent({
1643
+ type: "allocationChange",
1644
+ toMount: [],
1645
+ toUnmount: [...state.activeAllocations]
1646
+ });
1647
+ }
1648
+ this.store.setState(createInitialState4());
1649
+ this.logger?.debug("[ResourceGovernor] Deactivated");
1650
+ }
1651
+ /**
1652
+ * Destroy the resource governor
1653
+ */
1654
+ destroy() {
1655
+ this.deactivate();
1656
+ this.eventListeners.clear();
1657
+ this.logger?.debug("[ResourceGovernor] Destroyed");
1658
+ }
1659
+ // ═══════════════════════════════════════════════════════════════
1660
+ // PUBLIC API - FEED MANAGEMENT
1661
+ // ═══════════════════════════════════════════════════════════════
1662
+ /**
1663
+ * Set total number of items in feed
1664
+ */
1665
+ setTotalItems(count) {
1666
+ const state = this.store.getState();
1667
+ if (state.totalItems === count) return;
1668
+ this.store.setState({ totalItems: count });
1669
+ if (state.isActive) {
1670
+ this.performAllocation(state.focusedIndex);
1671
+ }
1672
+ }
1673
+ /**
1674
+ * Set focused index with debouncing
1675
+ * This is called during scroll/swipe
1676
+ */
1677
+ setFocusedIndex(index) {
1678
+ const state = this.store.getState();
1679
+ const clampedIndex = Math.max(0, Math.min(index, state.totalItems - 1));
1680
+ if (clampedIndex === state.focusedIndex && this.pendingFocusedIndex === null) {
1681
+ return;
1682
+ }
1683
+ this.pendingFocusedIndex = clampedIndex;
1684
+ this.trackFocusChange();
1685
+ if (this.focusDebounceTimer) {
1686
+ clearTimeout(this.focusDebounceTimer);
1687
+ }
1688
+ this.focusDebounceTimer = setTimeout(() => {
1689
+ this.focusDebounceTimer = null;
1690
+ if (this.pendingFocusedIndex !== null) {
1691
+ const previousIndex = this.store.getState().focusedIndex;
1692
+ const newIndex = this.pendingFocusedIndex;
1693
+ this.pendingFocusedIndex = null;
1694
+ this.store.setState({ focusedIndex: newIndex });
1695
+ this.emitEvent({
1696
+ type: "focusChange",
1697
+ index: newIndex,
1698
+ previousIndex
1699
+ });
1700
+ if (this.store.getState().isActive) {
1701
+ this.performAllocation(newIndex);
1702
+ }
1703
+ }
1704
+ }, this.config.focusDebounceMs);
1705
+ }
1706
+ /**
1707
+ * Set focused index immediately (skip debounce)
1708
+ * Use this for programmatic navigation, not scroll
1709
+ */
1710
+ setFocusedIndexImmediate(index) {
1711
+ const state = this.store.getState();
1712
+ const clampedIndex = Math.max(0, Math.min(index, state.totalItems - 1));
1713
+ if (this.focusDebounceTimer) {
1714
+ clearTimeout(this.focusDebounceTimer);
1715
+ this.focusDebounceTimer = null;
1716
+ }
1717
+ this.pendingFocusedIndex = null;
1718
+ const previousIndex = state.focusedIndex;
1719
+ if (clampedIndex !== previousIndex) {
1720
+ this.store.setState({ focusedIndex: clampedIndex });
1721
+ this.emitEvent({
1722
+ type: "focusChange",
1723
+ index: clampedIndex,
1724
+ previousIndex
1725
+ });
1726
+ if (state.isActive) {
1727
+ this.performAllocation(clampedIndex);
1728
+ }
1729
+ }
1730
+ }
1731
+ // ═══════════════════════════════════════════════════════════════
1732
+ // PUBLIC API - ALLOCATION
1733
+ // ═══════════════════════════════════════════════════════════════
1734
+ /**
1735
+ * Request allocation for given indices
1736
+ * Returns what needs to be mounted/unmounted
1737
+ */
1738
+ requestAllocation(indices) {
1739
+ const state = this.store.getState();
1740
+ const validIndices = indices.filter((i) => i >= 0 && i < state.totalItems);
1741
+ const limitedIndices = validIndices.slice(0, this.config.maxAllocations);
1742
+ const changes = computeAllocationChanges(state.activeAllocations, limitedIndices);
1743
+ this.store.setState({ activeAllocations: new Set(limitedIndices) });
1744
+ if (changes.toMount.length > 0 || changes.toUnmount.length > 0) {
1745
+ this.emitEvent({
1746
+ type: "allocationChange",
1747
+ toMount: changes.toMount,
1748
+ toUnmount: changes.toUnmount
1749
+ });
1750
+ }
1751
+ return { ...changes, success: true };
1752
+ }
1753
+ /**
1754
+ * Get current active allocations
1755
+ */
1756
+ getActiveAllocations() {
1757
+ return [...this.store.getState().activeAllocations];
1758
+ }
1759
+ /**
1760
+ * Check if an index is currently allocated
1761
+ */
1762
+ isAllocated(index) {
1763
+ return this.store.getState().activeAllocations.has(index);
1764
+ }
1765
+ // ═══════════════════════════════════════════════════════════════
1766
+ // PUBLIC API - NETWORK
1767
+ // ═══════════════════════════════════════════════════════════════
1768
+ /**
1769
+ * Get current network type
1770
+ */
1771
+ getNetworkType() {
1772
+ return this.store.getState().networkType;
1773
+ }
1774
+ /**
1775
+ * Get prefetch configuration for current network
1776
+ */
1777
+ getPrefetchConfig() {
1778
+ const networkType = this.store.getState().networkType;
1779
+ return this.config.prefetch[networkType];
1780
+ }
1781
+ // ═══════════════════════════════════════════════════════════════
1782
+ // PUBLIC API - PRELOAD
1783
+ // ═══════════════════════════════════════════════════════════════
1784
+ /**
1785
+ * Set video source getter function
1786
+ * This is called by SDK to provide video data for preloading
1787
+ *
1788
+ * @param getter - Function that returns video info for a given index
1789
+ */
1790
+ setVideoSourceGetter(getter) {
1791
+ this.videoSourceGetter = getter;
1792
+ this.logger?.debug("[ResourceGovernor] Video source getter set");
1793
+ }
1794
+ /**
1795
+ * Check if preloading is currently throttled due to scroll thrashing
1796
+ */
1797
+ isPreloadThrottled() {
1798
+ return this.store.getState().isThrottled;
1799
+ }
1800
+ /**
1801
+ * Get indices currently being preloaded
1802
+ */
1803
+ getPreloadingIndices() {
1804
+ return [...this.store.getState().preloadingIndices];
1805
+ }
1806
+ /**
1807
+ * Manually trigger preload for specific indices
1808
+ * Respects throttling and network conditions
1809
+ */
1810
+ async triggerPreload(indices) {
1811
+ const state = this.store.getState();
1812
+ if (state.isThrottled || !state.isActive) {
1813
+ this.logger?.debug("[ResourceGovernor] Preload skipped - throttled or inactive");
1814
+ return;
1815
+ }
1816
+ const prefetchConfig = this.getPrefetchConfig();
1817
+ if (!prefetchConfig.prefetchVideo) {
1818
+ this.logger?.debug("[ResourceGovernor] Preload skipped - network does not allow");
1819
+ return;
1820
+ }
1821
+ await this.executePreload(indices);
1822
+ }
1823
+ // ═══════════════════════════════════════════════════════════════
1824
+ // PUBLIC API - EVENTS
1825
+ // ═══════════════════════════════════════════════════════════════
1826
+ /**
1827
+ * Add event listener
1828
+ */
1829
+ addEventListener(listener) {
1830
+ this.eventListeners.add(listener);
1831
+ return () => this.eventListeners.delete(listener);
1832
+ }
1833
+ /**
1834
+ * Remove event listener
1835
+ */
1836
+ removeEventListener(listener) {
1837
+ this.eventListeners.delete(listener);
1838
+ }
1839
+ // ═══════════════════════════════════════════════════════════════
1840
+ // PRIVATE METHODS
1841
+ // ═══════════════════════════════════════════════════════════════
1842
+ /**
1843
+ * Initialize network detection
1844
+ */
1845
+ async initializeNetwork() {
1846
+ if (!this.networkAdapter) {
1847
+ this.store.setState({ networkType: "wifi" });
1848
+ return;
1849
+ }
1850
+ try {
1851
+ const rawNetworkType = await this.networkAdapter.getNetworkType();
1852
+ const networkType = mapNetworkType(rawNetworkType);
1853
+ this.store.setState({ networkType });
1854
+ this.networkUnsubscribe = this.networkAdapter.onNetworkChange((type) => {
1855
+ const newType = mapNetworkType(type);
1856
+ const previousType = this.store.getState().networkType;
1857
+ if (newType !== previousType) {
1858
+ this.store.setState({ networkType: newType });
1859
+ this.emitEvent({ type: "networkChange", networkType: newType });
1860
+ this.updatePrefetchQueue();
1861
+ }
1862
+ });
1863
+ } catch {
1864
+ this.logger?.warn("[ResourceGovernor] Failed to detect network, defaulting to wifi");
1865
+ this.store.setState({ networkType: "wifi" });
1866
+ }
1867
+ }
1868
+ /**
1869
+ * Perform allocation for a given focused index
1870
+ */
1871
+ performAllocation(focusedIndex) {
1872
+ const state = this.store.getState();
1873
+ const windowIndices = calculateWindowIndices(
1874
+ focusedIndex,
1875
+ state.totalItems,
1876
+ this.config.maxAllocations
1877
+ );
1878
+ this.requestAllocation(windowIndices);
1879
+ this.updatePrefetchQueue();
1880
+ }
1881
+ /**
1882
+ * Update prefetch queue based on current state
1883
+ */
1884
+ updatePrefetchQueue() {
1885
+ const state = this.store.getState();
1886
+ const prefetchConfig = this.getPrefetchConfig();
1887
+ const posterPrefetchIndices = calculatePrefetchIndices(
1888
+ state.focusedIndex,
1889
+ state.totalItems,
1890
+ prefetchConfig.posterCount,
1891
+ [...state.activeAllocations]
1892
+ );
1893
+ const videoPrefetchIndices = calculatePrefetchIndices(
1894
+ state.focusedIndex,
1895
+ state.totalItems,
1896
+ prefetchConfig.videoSegmentCount,
1897
+ [...state.activeAllocations]
1898
+ );
1899
+ if (posterPrefetchIndices.length > 0) {
1900
+ this.store.setState({ preloadQueue: posterPrefetchIndices });
1901
+ this.emitEvent({ type: "prefetchRequest", indices: posterPrefetchIndices });
1902
+ if (!state.isThrottled && prefetchConfig.prefetchVideo) {
1903
+ this.executePreload(videoPrefetchIndices);
1904
+ }
1905
+ this.executePreloadPosters(posterPrefetchIndices);
1906
+ }
1907
+ }
1908
+ // ═══════════════════════════════════════════════════════════════
1909
+ // PRIVATE METHODS - SCROLL THRASHING
1910
+ // ═══════════════════════════════════════════════════════════════
1911
+ /**
1912
+ * Track focus change timestamp for scroll thrashing detection
1913
+ */
1914
+ trackFocusChange() {
1915
+ const now = Date.now();
1916
+ const { windowMs, maxChangesInWindow, cooldownMs } = this.config.scrollThrashing;
1917
+ this.focusChangeTimestamps.push(now);
1918
+ const windowStart = now - windowMs;
1919
+ this.focusChangeTimestamps = this.focusChangeTimestamps.filter((t) => t >= windowStart);
1920
+ const isCurrentlyThrashing = this.focusChangeTimestamps.length > maxChangesInWindow;
1921
+ const wasThrottled = this.store.getState().isThrottled;
1922
+ if (isCurrentlyThrashing && !wasThrottled) {
1923
+ this.store.setState({ isThrottled: true });
1924
+ this.logger?.debug(
1925
+ `[ResourceGovernor] Scroll thrashing detected (${this.focusChangeTimestamps.length} changes in ${windowMs}ms) - throttling preload`
1926
+ );
1927
+ this.cancelAllPreloads();
1928
+ if (this.thrashingCooldownTimer) {
1929
+ clearTimeout(this.thrashingCooldownTimer);
1930
+ }
1931
+ }
1932
+ if (wasThrottled || isCurrentlyThrashing) {
1933
+ if (this.thrashingCooldownTimer) {
1934
+ clearTimeout(this.thrashingCooldownTimer);
1935
+ }
1936
+ this.thrashingCooldownTimer = setTimeout(() => {
1937
+ this.thrashingCooldownTimer = null;
1938
+ this.store.setState({ isThrottled: false });
1939
+ this.logger?.debug(
1940
+ "[ResourceGovernor] Scroll thrashing cooldown complete - resuming preload"
1941
+ );
1942
+ if (this.store.getState().isActive) {
1943
+ this.updatePrefetchQueue();
1944
+ }
1945
+ }, cooldownMs);
1946
+ }
1947
+ }
1948
+ /**
1949
+ * Cancel all in-progress preloads
1950
+ */
1951
+ cancelAllPreloads() {
1952
+ if (!this.videoLoader) return;
1953
+ const state = this.store.getState();
1954
+ for (const index of state.preloadingIndices) {
1955
+ const videoInfo = this.videoSourceGetter?.(index);
1956
+ if (videoInfo) {
1957
+ this.videoLoader.cancelPreload(videoInfo.id);
1958
+ this.logger?.debug(`[ResourceGovernor] Cancelled preload for index ${index}`);
1959
+ }
1960
+ }
1961
+ this.store.setState({ preloadingIndices: /* @__PURE__ */ new Set() });
1962
+ }
1963
+ // ═══════════════════════════════════════════════════════════════
1964
+ // PRIVATE METHODS - PRELOADING
1965
+ // ═══════════════════════════════════════════════════════════════
1966
+ /**
1967
+ * Execute video preloading for given indices
1968
+ */
1969
+ async executePreload(indices) {
1970
+ if (!this.videoLoader || !this.videoSourceGetter) {
1971
+ return;
1972
+ }
1973
+ const state = this.store.getState();
1974
+ const indicesToPreload = indices.filter((index) => {
1975
+ const videoInfo = this.videoSourceGetter?.(index);
1976
+ if (!videoInfo) return false;
1977
+ if (state.preloadingIndices.has(index)) return false;
1978
+ if (this.videoLoader?.isPreloaded(videoInfo.id)) return false;
1979
+ return true;
1980
+ });
1981
+ if (indicesToPreload.length === 0) return;
1982
+ const newPreloadingIndices = new Set(state.preloadingIndices);
1983
+ for (const index of indicesToPreload) {
1984
+ newPreloadingIndices.add(index);
1985
+ }
1986
+ this.store.setState({ preloadingIndices: newPreloadingIndices });
1987
+ const preloadPromises = indicesToPreload.map(async (index) => {
1988
+ const videoInfo = this.videoSourceGetter?.(index);
1989
+ if (!videoInfo) return;
1990
+ try {
1991
+ this.logger?.debug(
1992
+ `[ResourceGovernor] Preloading video at index ${index} (${videoInfo.id})`
1993
+ );
1994
+ const result = await this.videoLoader?.preload(videoInfo.id, videoInfo.source, {
1995
+ priority: indicesToPreload.indexOf(index)
1996
+ // Lower index = higher priority
1997
+ });
1998
+ if (result?.status === "ready") {
1999
+ this.logger?.debug(
2000
+ `[ResourceGovernor] Preload complete for index ${index} (${result.loadedBytes ?? 0} bytes)`
2001
+ );
2002
+ } else if (result?.status === "error") {
2003
+ this.logger?.warn(
2004
+ `[ResourceGovernor] Preload failed for index ${index}: ${result.error?.message}`
2005
+ );
2006
+ }
2007
+ } catch (err) {
2008
+ this.logger?.error(
2009
+ "[ResourceGovernor] Preload error",
2010
+ err instanceof Error ? err : new Error(String(err))
2011
+ );
2012
+ } finally {
2013
+ const currentState = this.store.getState();
2014
+ const updatedPreloadingIndices = new Set(currentState.preloadingIndices);
2015
+ updatedPreloadingIndices.delete(index);
2016
+ this.store.setState({ preloadingIndices: updatedPreloadingIndices });
2017
+ }
2018
+ });
2019
+ await Promise.allSettled(preloadPromises);
2020
+ }
2021
+ /**
2022
+ * Execute poster preloading for given indices
2023
+ */
2024
+ async executePreloadPosters(indices) {
2025
+ if (!this.posterLoader || !this.videoSourceGetter) {
2026
+ return;
2027
+ }
2028
+ const preloadPromises = indices.map(async (index) => {
2029
+ const videoInfo = this.videoSourceGetter?.(index);
2030
+ if (!videoInfo?.poster) return;
2031
+ if (this.posterLoader?.isCached(videoInfo.poster)) return;
2032
+ try {
2033
+ await this.posterLoader?.preload(videoInfo.poster);
2034
+ this.logger?.debug(`[ResourceGovernor] Poster preloaded for index ${index}`);
2035
+ } catch {
2036
+ }
2037
+ });
2038
+ await Promise.allSettled(preloadPromises);
2039
+ }
2040
+ /**
2041
+ * Emit event to all listeners
2042
+ */
2043
+ emitEvent(event) {
2044
+ for (const listener of this.eventListeners) {
2045
+ try {
2046
+ listener(event);
2047
+ } catch (err) {
2048
+ this.logger?.error(
2049
+ "[ResourceGovernor] Event listener error",
2050
+ err instanceof Error ? err : new Error(String(err))
2051
+ );
2052
+ }
2053
+ }
2054
+ }
2055
+ };
2056
+
2057
+ // src/optimistic/types.ts
2058
+ var DEFAULT_OPTIMISTIC_CONFIG = {
2059
+ maxRetries: 3,
2060
+ retryDelayMs: 1e3,
2061
+ autoRetry: true
2062
+ };
2063
+
2064
+ // src/optimistic/OptimisticManager.ts
2065
+ var generateActionId = () => {
2066
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
2067
+ };
2068
+ var createInitialState5 = () => ({
2069
+ pendingActions: /* @__PURE__ */ new Map(),
2070
+ failedQueue: [],
2071
+ hasPending: false,
2072
+ isRetrying: false
2073
+ });
2074
+ var OptimisticManager = class {
2075
+ constructor(config = {}) {
2076
+ /** Event listeners */
2077
+ this.eventListeners = /* @__PURE__ */ new Set();
2078
+ /** Retry timer */
2079
+ this.retryTimer = null;
2080
+ const { interaction, feedManager, logger, ...restConfig } = config;
2081
+ this.config = { ...DEFAULT_OPTIMISTIC_CONFIG, ...restConfig };
2082
+ this.interaction = interaction;
2083
+ this.feedManager = feedManager;
2084
+ this.logger = logger;
2085
+ this.store = createStore(createInitialState5);
2086
+ }
2087
+ // ═══════════════════════════════════════════════════════════════
2088
+ // PUBLIC API - ACTIONS
2089
+ // ═══════════════════════════════════════════════════════════════
2090
+ /**
2091
+ * Like a video with optimistic update
2092
+ */
2093
+ async like(videoId) {
2094
+ return this.performAction("like", videoId, async () => {
2095
+ if (!this.interaction) throw new Error("No interaction adapter");
2096
+ await this.interaction.like(videoId);
2097
+ });
2098
+ }
2099
+ /**
2100
+ * Unlike a video with optimistic update
2101
+ */
2102
+ async unlike(videoId) {
2103
+ return this.performAction("unlike", videoId, async () => {
2104
+ if (!this.interaction) throw new Error("No interaction adapter");
2105
+ await this.interaction.unlike(videoId);
2106
+ });
2107
+ }
2108
+ /**
2109
+ * Toggle like state (like if not liked, unlike if liked)
2110
+ */
2111
+ async toggleLike(videoId) {
2112
+ const video = this.feedManager?.getVideo(videoId);
2113
+ if (!video) {
2114
+ this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
2115
+ return false;
2116
+ }
2117
+ return video.isLiked ? this.unlike(videoId) : this.like(videoId);
2118
+ }
2119
+ /**
2120
+ * Follow a video author with optimistic update
2121
+ */
2122
+ async follow(videoId) {
2123
+ return this.performAction("follow", videoId, async () => {
2124
+ if (!this.interaction) throw new Error("No interaction adapter");
2125
+ await this.interaction.follow(videoId);
2126
+ });
2127
+ }
2128
+ /**
2129
+ * Unfollow a video author with optimistic update
2130
+ */
2131
+ async unfollow(videoId) {
2132
+ return this.performAction("unfollow", videoId, async () => {
2133
+ if (!this.interaction) throw new Error("No interaction adapter");
2134
+ await this.interaction.unfollow(videoId);
2135
+ });
2136
+ }
2137
+ /**
2138
+ * Toggle follow state
2139
+ */
2140
+ async toggleFollow(videoId) {
2141
+ const video = this.feedManager?.getVideo(videoId);
2142
+ if (!video) {
2143
+ this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
2144
+ return false;
2145
+ }
2146
+ return video.isFollowing ? this.unfollow(videoId) : this.follow(videoId);
2147
+ }
2148
+ // ═══════════════════════════════════════════════════════════════
2149
+ // PUBLIC API - STATE MANAGEMENT
2150
+ // ═══════════════════════════════════════════════════════════════
2151
+ /**
2152
+ * Get all pending actions
2153
+ */
2154
+ getPendingActions() {
2155
+ return [...this.store.getState().pendingActions.values()];
2156
+ }
2157
+ /**
2158
+ * Check if there's a pending action for a video
2159
+ */
2160
+ hasPendingAction(videoId, type) {
2161
+ const actions = this.store.getState().pendingActions;
2162
+ for (const action of actions.values()) {
2163
+ if (action.videoId === videoId && (!type || action.type === type)) {
2164
+ return true;
2165
+ }
2166
+ }
2167
+ return false;
2168
+ }
2169
+ /**
2170
+ * Get failed actions queue
2171
+ */
2172
+ getFailedQueue() {
2173
+ const state = this.store.getState();
2174
+ return state.failedQueue.map((id) => state.pendingActions.get(id)).filter((a) => a !== void 0);
2175
+ }
2176
+ /**
2177
+ * Manually retry failed actions
2178
+ */
2179
+ async retryFailed() {
2180
+ const state = this.store.getState();
2181
+ if (state.isRetrying || state.failedQueue.length === 0) return;
2182
+ this.store.setState({ isRetrying: true });
2183
+ const failedQueue = [...state.failedQueue];
2184
+ for (const actionId of failedQueue) {
2185
+ const action = state.pendingActions.get(actionId);
2186
+ if (action && action.retryCount < this.config.maxRetries) {
2187
+ await this.retryAction(action);
2188
+ }
2189
+ }
2190
+ this.store.setState({ isRetrying: false });
2191
+ }
2192
+ /**
2193
+ * Clear all failed actions
2194
+ */
2195
+ clearFailed() {
2196
+ const state = this.store.getState();
2197
+ const newPendingActions = new Map(state.pendingActions);
2198
+ for (const actionId of state.failedQueue) {
2199
+ const action = newPendingActions.get(actionId);
2200
+ if (action) {
2201
+ this.applyRollback(action);
2202
+ newPendingActions.delete(actionId);
2203
+ }
2204
+ }
2205
+ this.store.setState({
2206
+ pendingActions: newPendingActions,
2207
+ failedQueue: [],
2208
+ hasPending: newPendingActions.size > 0
2209
+ });
2210
+ }
2211
+ // ═══════════════════════════════════════════════════════════════
2212
+ // PUBLIC API - LIFECYCLE
2213
+ // ═══════════════════════════════════════════════════════════════
2214
+ /**
2215
+ * Reset manager state
2216
+ */
2217
+ reset() {
2218
+ this.cancelRetryTimer();
2219
+ this.store.setState(createInitialState5());
2220
+ }
2221
+ /**
2222
+ * Destroy manager and cleanup
2223
+ */
2224
+ destroy() {
2225
+ this.cancelRetryTimer();
2226
+ this.eventListeners.clear();
2227
+ this.store.setState(createInitialState5());
2228
+ }
2229
+ // ═══════════════════════════════════════════════════════════════
2230
+ // PUBLIC API - EVENTS
2231
+ // ═══════════════════════════════════════════════════════════════
2232
+ /**
2233
+ * Add event listener
2234
+ */
2235
+ addEventListener(listener) {
2236
+ this.eventListeners.add(listener);
2237
+ return () => this.eventListeners.delete(listener);
2238
+ }
2239
+ /**
2240
+ * Remove event listener
2241
+ */
2242
+ removeEventListener(listener) {
2243
+ this.eventListeners.delete(listener);
2244
+ }
2245
+ // ═══════════════════════════════════════════════════════════════
2246
+ // PRIVATE METHODS
2247
+ // ═══════════════════════════════════════════════════════════════
2248
+ /**
2249
+ * Perform an optimistic action
2250
+ */
2251
+ async performAction(type, videoId, apiCall) {
2252
+ if (this.hasPendingAction(videoId, type)) {
2253
+ this.logger?.debug(`[OptimisticManager] Duplicate action skipped: ${type} ${videoId}`);
2254
+ return false;
2255
+ }
2256
+ const video = this.feedManager?.getVideo(videoId);
2257
+ if (!video) {
2258
+ this.logger?.warn(`[OptimisticManager] Video not found: ${videoId}`);
2259
+ return false;
2260
+ }
2261
+ const action = {
2262
+ id: generateActionId(),
2263
+ type,
2264
+ videoId,
2265
+ rollbackData: this.createRollbackData(type, video),
2266
+ timestamp: Date.now(),
2267
+ status: "pending",
2268
+ retryCount: 0
2269
+ };
2270
+ this.addPendingAction(action);
2271
+ this.emitEvent({ type: "actionStart", action });
2272
+ this.applyOptimisticUpdate(type, video);
2273
+ this.logger?.debug(`[OptimisticManager] Action started: ${type} ${videoId}`);
2274
+ try {
2275
+ await apiCall();
2276
+ this.markActionSuccess(action.id);
2277
+ this.emitEvent({ type: "actionSuccess", action: { ...action, status: "success" } });
2278
+ this.logger?.debug(`[OptimisticManager] Action succeeded: ${type} ${videoId}`);
2279
+ return true;
2280
+ } catch (error) {
2281
+ const err = error instanceof Error ? error : new Error(String(error));
2282
+ this.markActionFailed(action.id, err.message);
2283
+ this.applyRollback(action);
2284
+ this.emitEvent({ type: "actionRollback", action: { ...action, status: "failed" } });
2285
+ this.emitEvent({ type: "actionFailed", action: { ...action, status: "failed" }, error: err });
2286
+ this.logger?.warn(`[OptimisticManager] Action failed: ${type} ${videoId}`, {
2287
+ error: err.message
2288
+ });
2289
+ if (this.config.autoRetry) {
2290
+ this.scheduleRetry();
2291
+ }
2292
+ return false;
2293
+ }
2294
+ }
2295
+ /**
2296
+ * Create rollback data based on action type
2297
+ */
2298
+ createRollbackData(type, video) {
2299
+ switch (type) {
2300
+ case "like":
2301
+ return {
2302
+ isLiked: video.isLiked,
2303
+ stats: { ...video.stats }
2304
+ };
2305
+ case "unlike":
2306
+ return {
2307
+ isLiked: video.isLiked,
2308
+ stats: { ...video.stats }
2309
+ };
2310
+ case "follow":
2311
+ case "unfollow":
2312
+ return {
2313
+ isFollowing: video.isFollowing
2314
+ };
2315
+ default:
2316
+ return {};
2317
+ }
2318
+ }
2319
+ /**
2320
+ * Apply optimistic update to feed
2321
+ */
2322
+ applyOptimisticUpdate(type, video) {
2323
+ if (!this.feedManager) return;
2324
+ switch (type) {
2325
+ case "like":
2326
+ this.feedManager.updateVideo(video.id, {
2327
+ isLiked: true,
2328
+ stats: { ...video.stats, likes: video.stats.likes + 1 }
2329
+ });
2330
+ break;
2331
+ case "unlike":
2332
+ this.feedManager.updateVideo(video.id, {
2333
+ isLiked: false,
2334
+ stats: { ...video.stats, likes: Math.max(0, video.stats.likes - 1) }
2335
+ });
2336
+ break;
2337
+ case "follow":
2338
+ this.feedManager.updateVideo(video.id, {
2339
+ isFollowing: true
2340
+ });
2341
+ break;
2342
+ case "unfollow":
2343
+ this.feedManager.updateVideo(video.id, {
2344
+ isFollowing: false
2345
+ });
2346
+ break;
2347
+ }
2348
+ }
2349
+ /**
2350
+ * Apply rollback
2351
+ */
2352
+ applyRollback(action) {
2353
+ if (!this.feedManager) return;
2354
+ this.feedManager.updateVideo(action.videoId, action.rollbackData);
2355
+ }
2356
+ /**
2357
+ * Add pending action to store
2358
+ */
2359
+ addPendingAction(action) {
2360
+ const state = this.store.getState();
2361
+ const newPendingActions = new Map(state.pendingActions);
2362
+ newPendingActions.set(action.id, action);
2363
+ this.store.setState({
2364
+ pendingActions: newPendingActions,
2365
+ hasPending: true
2366
+ });
2367
+ }
2368
+ /**
2369
+ * Mark action as success
2370
+ */
2371
+ markActionSuccess(actionId) {
2372
+ const state = this.store.getState();
2373
+ const newPendingActions = new Map(state.pendingActions);
2374
+ newPendingActions.delete(actionId);
2375
+ const newFailedQueue = state.failedQueue.filter((id) => id !== actionId);
2376
+ this.store.setState({
2377
+ pendingActions: newPendingActions,
2378
+ failedQueue: newFailedQueue,
2379
+ hasPending: newPendingActions.size > 0
2380
+ });
2381
+ }
2382
+ /**
2383
+ * Mark action as failed
2384
+ */
2385
+ markActionFailed(actionId, error) {
2386
+ const state = this.store.getState();
2387
+ const action = state.pendingActions.get(actionId);
2388
+ if (!action) return;
2389
+ const newPendingActions = new Map(state.pendingActions);
2390
+ newPendingActions.set(actionId, {
2391
+ ...action,
2392
+ status: "failed",
2393
+ error
2394
+ });
2395
+ const newFailedQueue = state.failedQueue.includes(actionId) ? state.failedQueue : [...state.failedQueue, actionId];
2396
+ this.store.setState({
2397
+ pendingActions: newPendingActions,
2398
+ failedQueue: newFailedQueue
2399
+ });
2400
+ }
2401
+ /**
2402
+ * Retry a failed action
2403
+ */
2404
+ async retryAction(action) {
2405
+ this.emitEvent({ type: "retryStart", actionId: action.id });
2406
+ const state = this.store.getState();
2407
+ const newPendingActions = new Map(state.pendingActions);
2408
+ const updatedAction = {
2409
+ ...action,
2410
+ retryCount: action.retryCount + 1,
2411
+ status: "pending",
2412
+ error: void 0
2413
+ };
2414
+ newPendingActions.set(action.id, updatedAction);
2415
+ const newFailedQueue = state.failedQueue.filter((id) => id !== action.id);
2416
+ this.store.setState({
2417
+ pendingActions: newPendingActions,
2418
+ failedQueue: newFailedQueue
2419
+ });
2420
+ const video = this.feedManager?.getVideo(action.videoId);
2421
+ if (video) {
2422
+ this.applyOptimisticUpdate(action.type, video);
2423
+ }
2424
+ try {
2425
+ await this.executeApiCall(action.type, action.videoId);
2426
+ this.markActionSuccess(action.id);
2427
+ this.emitEvent({ type: "actionSuccess", action: { ...updatedAction, status: "success" } });
2428
+ } catch (error) {
2429
+ const err = error instanceof Error ? error : new Error(String(error));
2430
+ if (updatedAction.retryCount >= this.config.maxRetries) {
2431
+ this.emitEvent({ type: "retryExhausted", action: updatedAction });
2432
+ this.markActionFailed(action.id, "Max retries exhausted");
2433
+ } else {
2434
+ this.markActionFailed(action.id, err.message);
2435
+ }
2436
+ this.applyRollback(action);
2437
+ }
2438
+ }
2439
+ /**
2440
+ * Execute API call based on action type
2441
+ */
2442
+ async executeApiCall(type, videoId) {
2443
+ if (!this.interaction) throw new Error("No interaction adapter");
2444
+ switch (type) {
2445
+ case "like":
2446
+ await this.interaction.like(videoId);
2447
+ break;
2448
+ case "unlike":
2449
+ await this.interaction.unlike(videoId);
2450
+ break;
2451
+ case "follow":
2452
+ await this.interaction.follow(videoId);
2453
+ break;
2454
+ case "unfollow":
2455
+ await this.interaction.unfollow(videoId);
2456
+ break;
2457
+ }
2458
+ }
2459
+ /**
2460
+ * Schedule retry of failed actions
2461
+ */
2462
+ scheduleRetry() {
2463
+ if (this.retryTimer) return;
2464
+ this.retryTimer = setTimeout(() => {
2465
+ this.retryTimer = null;
2466
+ this.retryFailed();
2467
+ }, this.config.retryDelayMs);
2468
+ }
2469
+ /**
2470
+ * Cancel retry timer
2471
+ */
2472
+ cancelRetryTimer() {
2473
+ if (this.retryTimer) {
2474
+ clearTimeout(this.retryTimer);
2475
+ this.retryTimer = null;
2476
+ }
2477
+ }
2478
+ /**
2479
+ * Emit event to all listeners
2480
+ */
2481
+ emitEvent(event) {
2482
+ for (const listener of this.eventListeners) {
2483
+ try {
2484
+ listener(event);
2485
+ } catch (err) {
2486
+ this.logger?.error(
2487
+ "[OptimisticManager] Event listener error",
2488
+ err instanceof Error ? err : new Error(String(err))
2489
+ );
2490
+ }
2491
+ }
2492
+ }
2493
+ };
2494
+
2495
+ export { DEFAULT_FEED_CONFIG, DEFAULT_LIFECYCLE_CONFIG, DEFAULT_OPTIMISTIC_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_PREFETCH_CONFIG, DEFAULT_RESOURCE_CONFIG, FeedManager, LifecycleManager, OptimisticManager, PlayerEngine, PlayerStatus, ResourceGovernor, calculatePrefetchIndices, calculateWindowIndices, canPause, canPlay, canSeek, computeAllocationChanges, isActiveState, isValidTransition, mapNetworkType };