Spaces:
Running
Running
Upload kimi-utils.js
Browse files- kimi-js/kimi-utils.js +194 -4
kimi-js/kimi-utils.js
CHANGED
@@ -370,6 +370,18 @@ class KimiVideoManager {
|
|
370 |
this._stickyUntil = 0;
|
371 |
this._pendingSwitches = [];
|
372 |
this._debug = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
373 |
}
|
374 |
|
375 |
/**
|
@@ -633,7 +645,30 @@ class KimiVideoManager {
|
|
633 |
this.neutralVideos = this.videoCategories.neutral;
|
634 |
|
635 |
const neutrals = this.neutralVideos || [];
|
636 |
-
neutrals
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
637 |
}
|
638 |
|
639 |
async init(database = null) {
|
@@ -642,6 +677,21 @@ class KimiVideoManager {
|
|
642 |
this._visibilityHandler = this.onVisibilityChange.bind(this);
|
643 |
document.addEventListener("visibilitychange", this._visibilityHandler);
|
644 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
645 |
}
|
646 |
|
647 |
onVisibilityChange() {
|
@@ -682,6 +732,7 @@ class KimiVideoManager {
|
|
682 |
}
|
683 |
this._stickyContext = null;
|
684 |
this._stickyUntil = 0;
|
|
|
685 |
}
|
686 |
// While an emotion video is playing (speaking), block non-speaking context switches
|
687 |
if (
|
@@ -1367,6 +1418,18 @@ class KimiVideoManager {
|
|
1367 |
}
|
1368 |
|
1369 |
loadAndSwitchVideo(videoSrc, priority = "normal") {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1370 |
// Avoid redundant loading if the requested source is already active or currently loading in inactive element
|
1371 |
const activeSrc = this.activeVideo?.querySelector("source")?.getAttribute("src");
|
1372 |
const inactiveSrc = this.inactiveVideo?.querySelector("source")?.getAttribute("src");
|
@@ -1432,22 +1495,46 @@ class KimiVideoManager {
|
|
1432 |
this.inactiveVideo.removeEventListener("canplay", this._currentLoadHandler);
|
1433 |
this.inactiveVideo.removeEventListener("loadeddata", this._currentLoadHandler);
|
1434 |
this.inactiveVideo.removeEventListener("error", this._currentErrorHandler);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1435 |
this.performSwitch();
|
1436 |
};
|
1437 |
this._currentLoadHandler = onReady;
|
1438 |
|
1439 |
const folder = getCharacterInfo(this.characterName).videoFolder;
|
1440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1441 |
|
1442 |
this._currentErrorHandler = e => {
|
1443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1444 |
this._loadingInProgress = false;
|
1445 |
if (this._loadTimeout) {
|
1446 |
clearTimeout(this._loadTimeout);
|
1447 |
this._loadTimeout = null;
|
1448 |
}
|
|
|
|
|
1449 |
if (videoSrc !== fallbackVideo) {
|
1450 |
// Try fallback video
|
|
|
1451 |
this.loadAndSwitchVideo(fallbackVideo, "high");
|
1452 |
} else {
|
1453 |
// Ultimate fallback: try any neutral video
|
@@ -1468,6 +1555,12 @@ class KimiVideoManager {
|
|
1468 |
this._switchInProgress = false;
|
1469 |
}
|
1470 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
1471 |
};
|
1472 |
|
1473 |
this.inactiveVideo.addEventListener("loadeddata", this._currentLoadHandler, { once: true });
|
@@ -1478,15 +1571,36 @@ class KimiVideoManager {
|
|
1478 |
queueMicrotask(() => onReady());
|
1479 |
}
|
1480 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1481 |
this._loadTimeout = setTimeout(() => {
|
1482 |
if (!fired) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1483 |
if (this.inactiveVideo.readyState >= 2) {
|
1484 |
onReady();
|
1485 |
} else {
|
1486 |
this._currentErrorHandler();
|
1487 |
}
|
1488 |
}
|
1489 |
-
},
|
1490 |
}
|
1491 |
|
1492 |
usePreloadedVideo(preloadedVideo, videoSrc) {
|
@@ -1533,6 +1647,19 @@ class KimiVideoManager {
|
|
1533 |
const src = this.activeVideo?.querySelector("source")?.getAttribute("src");
|
1534 |
const info = { context: this.currentContext, emotion: this.currentEmotion };
|
1535 |
console.log("🎬 VideoManager: Now playing:", src, info);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1536 |
} catch {}
|
1537 |
this._switchInProgress = false;
|
1538 |
this.setupEventListenersForContext(this.currentContext);
|
@@ -1553,11 +1680,47 @@ class KimiVideoManager {
|
|
1553 |
} else {
|
1554 |
// Non-promise play fallback
|
1555 |
this._switchInProgress = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1556 |
this.setupEventListenersForContext(this.currentContext);
|
1557 |
}
|
1558 |
});
|
1559 |
}
|
1560 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1561 |
_prefetch(src) {
|
1562 |
if (!src || this._prefetchCache.has(src) || this._prefetchInFlight.has(src)) return;
|
1563 |
if (this._prefetchCache.size + this._prefetchInFlight.size >= this._maxPrefetch) return;
|
@@ -1575,10 +1738,12 @@ class KimiVideoManager {
|
|
1575 |
};
|
1576 |
v.oncanplay = () => {
|
1577 |
this._prefetchCache.set(src, v);
|
|
|
1578 |
cleanup();
|
1579 |
};
|
1580 |
v.oncanplaythrough = () => {
|
1581 |
this._prefetchCache.set(src, v);
|
|
|
1582 |
cleanup();
|
1583 |
};
|
1584 |
v.onerror = () => {
|
@@ -1589,6 +1754,31 @@ class KimiVideoManager {
|
|
1589 |
} catch {}
|
1590 |
}
|
1591 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1592 |
_prefetchLikely(category) {
|
1593 |
const list = this.videoCategories[category] || [];
|
1594 |
// Prefetch 1-2 next likely videos different from current
|
|
|
370 |
this._stickyUntil = 0;
|
371 |
this._pendingSwitches = [];
|
372 |
this._debug = false;
|
373 |
+
// Adaptive timeout refinements (A+B+C)
|
374 |
+
this._maxTimeout = 6000; // Reduced upper bound (was 10000) for 10s clips
|
375 |
+
this._timeoutExtension = 1200; // Extension when metadata only
|
376 |
+
this._timeoutCapRatio = 0.7; // Cap total wait <= 70% clip length
|
377 |
+
// Initialize adaptive loading metrics and failure tracking
|
378 |
+
this._avgLoadTime = null;
|
379 |
+
this._loadTimeSamples = [];
|
380 |
+
this._maxSamples = 10;
|
381 |
+
this._minTimeout = 3000;
|
382 |
+
this._recentFailures = new Map();
|
383 |
+
this._failureCooldown = 5000;
|
384 |
+
this._consecutiveErrorCount = 0;
|
385 |
}
|
386 |
|
387 |
/**
|
|
|
645 |
this.neutralVideos = this.videoCategories.neutral;
|
646 |
|
647 |
const neutrals = this.neutralVideos || [];
|
648 |
+
// Progressive warm-up phase: start with only 2 neutrals (adaptive on network), others scheduled later
|
649 |
+
let neutralPrefetchCount = 2;
|
650 |
+
try {
|
651 |
+
const conn = navigator.connection || navigator.webkitConnection || navigator.mozConnection;
|
652 |
+
if (conn && conn.effectiveType) {
|
653 |
+
// Reduce on slower connections
|
654 |
+
if (/2g/i.test(conn.effectiveType)) neutralPrefetchCount = 1;
|
655 |
+
else if (/3g/i.test(conn.effectiveType)) neutralPrefetchCount = 2;
|
656 |
+
}
|
657 |
+
} catch {}
|
658 |
+
neutrals.slice(0, neutralPrefetchCount).forEach(src => this._prefetch(src));
|
659 |
+
|
660 |
+
// Schedule warm-up step 2: after 5s prefetch the 3rd neutral if not already cached
|
661 |
+
if (!this._warmupTimer) {
|
662 |
+
this._warmupTimer = setTimeout(() => {
|
663 |
+
try {
|
664 |
+
const target = neutrals[2];
|
665 |
+
if (target && !this._prefetchCache.has(target)) this._prefetch(target);
|
666 |
+
} catch {}
|
667 |
+
}, 5000);
|
668 |
+
}
|
669 |
+
|
670 |
+
// Mark waiting for first interaction to fetch 4th neutral later
|
671 |
+
this._awaitingFirstInteraction = true;
|
672 |
}
|
673 |
|
674 |
async init(database = null) {
|
|
|
677 |
this._visibilityHandler = this.onVisibilityChange.bind(this);
|
678 |
document.addEventListener("visibilitychange", this._visibilityHandler);
|
679 |
}
|
680 |
+
// Hook basic user interaction (first click / keypress) to advance warm-up
|
681 |
+
if (!this._firstInteractionHandler) {
|
682 |
+
this._firstInteractionHandler = () => {
|
683 |
+
if (this._awaitingFirstInteraction) {
|
684 |
+
this._awaitingFirstInteraction = false;
|
685 |
+
try {
|
686 |
+
const neutrals = this.neutralVideos || [];
|
687 |
+
const fourth = neutrals[3];
|
688 |
+
if (fourth && !this._prefetchCache.has(fourth)) this._prefetch(fourth);
|
689 |
+
} catch {}
|
690 |
+
}
|
691 |
+
};
|
692 |
+
window.addEventListener("click", this._firstInteractionHandler, { once: true });
|
693 |
+
window.addEventListener("keydown", this._firstInteractionHandler, { once: true });
|
694 |
+
}
|
695 |
}
|
696 |
|
697 |
onVisibilityChange() {
|
|
|
732 |
}
|
733 |
this._stickyContext = null;
|
734 |
this._stickyUntil = 0;
|
735 |
+
// Do not reset adaptive loading metrics here; preserve rolling stats across sticky context release
|
736 |
}
|
737 |
// While an emotion video is playing (speaking), block non-speaking context switches
|
738 |
if (
|
|
|
1418 |
}
|
1419 |
|
1420 |
loadAndSwitchVideo(videoSrc, priority = "normal") {
|
1421 |
+
const startTs = performance.now();
|
1422 |
+
// Guard: ignore if recently failed and still in cooldown
|
1423 |
+
const lastFail = this._recentFailures.get(videoSrc);
|
1424 |
+
if (lastFail && performance.now() - lastFail < this._failureCooldown) {
|
1425 |
+
// Pick an alternative neutral as quick substitution
|
1426 |
+
const neutralList = (this.videoCategories && this.videoCategories.neutral) || [];
|
1427 |
+
const alt = neutralList.find(v => v !== videoSrc) || neutralList[0];
|
1428 |
+
if (alt && alt !== videoSrc) {
|
1429 |
+
console.warn(`Skipping recently failed video (cooldown): ${videoSrc} -> trying alt: ${alt}`);
|
1430 |
+
return this.loadAndSwitchVideo(alt, priority);
|
1431 |
+
}
|
1432 |
+
}
|
1433 |
// Avoid redundant loading if the requested source is already active or currently loading in inactive element
|
1434 |
const activeSrc = this.activeVideo?.querySelector("source")?.getAttribute("src");
|
1435 |
const inactiveSrc = this.inactiveVideo?.querySelector("source")?.getAttribute("src");
|
|
|
1495 |
this.inactiveVideo.removeEventListener("canplay", this._currentLoadHandler);
|
1496 |
this.inactiveVideo.removeEventListener("loadeddata", this._currentLoadHandler);
|
1497 |
this.inactiveVideo.removeEventListener("error", this._currentErrorHandler);
|
1498 |
+
// Update rolling average load time
|
1499 |
+
const duration = performance.now() - startTs;
|
1500 |
+
this._loadTimeSamples.push(duration);
|
1501 |
+
if (this._loadTimeSamples.length > this._maxSamples) this._loadTimeSamples.shift();
|
1502 |
+
const sum = this._loadTimeSamples.reduce((a, b) => a + b, 0);
|
1503 |
+
this._avgLoadTime = sum / this._loadTimeSamples.length;
|
1504 |
+
this._consecutiveErrorCount = 0; // reset on success
|
1505 |
this.performSwitch();
|
1506 |
};
|
1507 |
this._currentLoadHandler = onReady;
|
1508 |
|
1509 |
const folder = getCharacterInfo(this.characterName).videoFolder;
|
1510 |
+
// Rotating fallback pool (stable neutrals first positions)
|
1511 |
+
if (!this._fallbackPool) {
|
1512 |
+
const neutralList = (this.videoCategories && this.videoCategories.neutral) || [];
|
1513 |
+
// Choose first 3 as "ultra reliable" (order curated manually in list)
|
1514 |
+
this._fallbackPool = neutralList.slice(0, 3);
|
1515 |
+
this._fallbackIndex = 0;
|
1516 |
+
}
|
1517 |
+
const fallbackVideo = this._fallbackPool[this._fallbackIndex % this._fallbackPool.length];
|
1518 |
|
1519 |
this._currentErrorHandler = e => {
|
1520 |
+
const mediaEl = this.inactiveVideo;
|
1521 |
+
const readyState = mediaEl ? mediaEl.readyState : -1;
|
1522 |
+
const networkState = mediaEl ? mediaEl.networkState : -1;
|
1523 |
+
let mediaErrorCode = null;
|
1524 |
+
if (mediaEl && mediaEl.error) mediaErrorCode = mediaEl.error.code;
|
1525 |
+
console.warn(
|
1526 |
+
`Error loading video: ${videoSrc} (readyState=${readyState} networkState=${networkState} mediaError=${mediaErrorCode}) falling back to: ${fallbackVideo}`
|
1527 |
+
);
|
1528 |
this._loadingInProgress = false;
|
1529 |
if (this._loadTimeout) {
|
1530 |
clearTimeout(this._loadTimeout);
|
1531 |
this._loadTimeout = null;
|
1532 |
}
|
1533 |
+
this._recentFailures.set(videoSrc, performance.now());
|
1534 |
+
this._consecutiveErrorCount++;
|
1535 |
if (videoSrc !== fallbackVideo) {
|
1536 |
// Try fallback video
|
1537 |
+
this._fallbackIndex = (this._fallbackIndex + 1) % this._fallbackPool.length; // advance for next time
|
1538 |
this.loadAndSwitchVideo(fallbackVideo, "high");
|
1539 |
} else {
|
1540 |
// Ultimate fallback: try any neutral video
|
|
|
1555 |
this._switchInProgress = false;
|
1556 |
}
|
1557 |
}
|
1558 |
+
// Escalate diagnostics if many consecutive errors
|
1559 |
+
if (this._consecutiveErrorCount >= 3) {
|
1560 |
+
console.info(
|
1561 |
+
`Diagnostics: avgLoadTime=${this._avgLoadTime?.toFixed(1) || "n/a"}ms samples=${this._loadTimeSamples.length} prefetchCache=${this._prefetchCache.size}`
|
1562 |
+
);
|
1563 |
+
}
|
1564 |
};
|
1565 |
|
1566 |
this.inactiveVideo.addEventListener("loadeddata", this._currentLoadHandler, { once: true });
|
|
|
1571 |
queueMicrotask(() => onReady());
|
1572 |
}
|
1573 |
|
1574 |
+
// Dynamic timeout: refined formula avg*1.5 + buffer, bounded
|
1575 |
+
let adaptiveTimeout = this._minTimeout;
|
1576 |
+
if (this._avgLoadTime) {
|
1577 |
+
adaptiveTimeout = Math.min(this._maxTimeout, Math.max(this._minTimeout, this._avgLoadTime * 1.5 + 400));
|
1578 |
+
}
|
1579 |
+
// Cap by clip length ratio if we know (assume 10000ms default when metadata absent)
|
1580 |
+
const currentClipMs = 10000; // All clips are 10s
|
1581 |
+
adaptiveTimeout = Math.min(adaptiveTimeout, Math.floor(currentClipMs * this._timeoutCapRatio));
|
1582 |
this._loadTimeout = setTimeout(() => {
|
1583 |
if (!fired) {
|
1584 |
+
// If metadata is there but not canplay yet, extend once
|
1585 |
+
if (this.inactiveVideo.readyState >= 1 && this.inactiveVideo.readyState < 2) {
|
1586 |
+
console.debug(
|
1587 |
+
`Extending timeout for ${videoSrc} (readyState=${this.inactiveVideo.readyState}) by ${this._timeoutExtension}ms`
|
1588 |
+
);
|
1589 |
+
this._loadTimeout = setTimeout(() => {
|
1590 |
+
if (!fired) {
|
1591 |
+
if (this.inactiveVideo.readyState >= 2) onReady();
|
1592 |
+
else this._currentErrorHandler();
|
1593 |
+
}
|
1594 |
+
}, this._timeoutExtension);
|
1595 |
+
return;
|
1596 |
+
}
|
1597 |
if (this.inactiveVideo.readyState >= 2) {
|
1598 |
onReady();
|
1599 |
} else {
|
1600 |
this._currentErrorHandler();
|
1601 |
}
|
1602 |
}
|
1603 |
+
}, adaptiveTimeout);
|
1604 |
}
|
1605 |
|
1606 |
usePreloadedVideo(preloadedVideo, videoSrc) {
|
|
|
1647 |
const src = this.activeVideo?.querySelector("source")?.getAttribute("src");
|
1648 |
const info = { context: this.currentContext, emotion: this.currentEmotion };
|
1649 |
console.log("🎬 VideoManager: Now playing:", src, info);
|
1650 |
+
// Recompute autoTransitionDuration from actual duration if available (C)
|
1651 |
+
try {
|
1652 |
+
const d = this.activeVideo.duration;
|
1653 |
+
if (!isNaN(d) && d > 0.5) {
|
1654 |
+
// Keep 1s headroom before natural end for auto scheduling
|
1655 |
+
const target = Math.max(1000, d * 1000 - 1100);
|
1656 |
+
this.autoTransitionDuration = target;
|
1657 |
+
} else {
|
1658 |
+
this.autoTransitionDuration = 9900; // fallback for 10s clips
|
1659 |
+
}
|
1660 |
+
// Dynamic neutral prefetch to widen diversity without burst
|
1661 |
+
this._prefetchNeutralDynamic();
|
1662 |
+
} catch {}
|
1663 |
} catch {}
|
1664 |
this._switchInProgress = false;
|
1665 |
this.setupEventListenersForContext(this.currentContext);
|
|
|
1680 |
} else {
|
1681 |
// Non-promise play fallback
|
1682 |
this._switchInProgress = false;
|
1683 |
+
try {
|
1684 |
+
const d = this.activeVideo.duration;
|
1685 |
+
if (!isNaN(d) && d > 0.5) {
|
1686 |
+
const target = Math.max(1000, d * 1000 - 1100);
|
1687 |
+
this.autoTransitionDuration = target;
|
1688 |
+
} else {
|
1689 |
+
this.autoTransitionDuration = 9900;
|
1690 |
+
}
|
1691 |
+
this._prefetchNeutralDynamic();
|
1692 |
+
} catch {}
|
1693 |
this.setupEventListenersForContext(this.currentContext);
|
1694 |
}
|
1695 |
});
|
1696 |
}
|
1697 |
|
1698 |
+
_prefetchNeutralDynamic() {
|
1699 |
+
try {
|
1700 |
+
const neutrals = (this.videoCategories && this.videoCategories.neutral) || [];
|
1701 |
+
if (!neutrals.length) return;
|
1702 |
+
// Build a set of already cached or in-flight
|
1703 |
+
const cached = new Set(
|
1704 |
+
[...this._prefetchCache.keys(), ...this._prefetchInFlight.values()].map(v => (typeof v === "string" ? v : v?.src))
|
1705 |
+
); // defensive
|
1706 |
+
const current = this.activeVideo?.querySelector("source")?.getAttribute("src");
|
1707 |
+
// Choose up to 2 unseen neutral videos different from current
|
1708 |
+
const candidates = neutrals.filter(s => s && s !== current && !cached.has(s));
|
1709 |
+
if (!candidates.length) return;
|
1710 |
+
let limit = 2;
|
1711 |
+
// Network-aware limiting
|
1712 |
+
try {
|
1713 |
+
const conn = navigator.connection || navigator.webkitConnection || navigator.mozConnection;
|
1714 |
+
if (conn && conn.effectiveType) {
|
1715 |
+
if (/2g/i.test(conn.effectiveType)) limit = 0;
|
1716 |
+
else if (/3g/i.test(conn.effectiveType)) limit = 1;
|
1717 |
+
}
|
1718 |
+
} catch {}
|
1719 |
+
if (limit <= 0) return;
|
1720 |
+
candidates.slice(0, limit).forEach(src => this._prefetch(src));
|
1721 |
+
} catch {}
|
1722 |
+
}
|
1723 |
+
|
1724 |
_prefetch(src) {
|
1725 |
if (!src || this._prefetchCache.has(src) || this._prefetchInFlight.has(src)) return;
|
1726 |
if (this._prefetchCache.size + this._prefetchInFlight.size >= this._maxPrefetch) return;
|
|
|
1738 |
};
|
1739 |
v.oncanplay = () => {
|
1740 |
this._prefetchCache.set(src, v);
|
1741 |
+
this._trimPrefetchCacheIfNeeded();
|
1742 |
cleanup();
|
1743 |
};
|
1744 |
v.oncanplaythrough = () => {
|
1745 |
this._prefetchCache.set(src, v);
|
1746 |
+
this._trimPrefetchCacheIfNeeded();
|
1747 |
cleanup();
|
1748 |
};
|
1749 |
v.onerror = () => {
|
|
|
1754 |
} catch {}
|
1755 |
}
|
1756 |
|
1757 |
+
_trimPrefetchCacheIfNeeded() {
|
1758 |
+
try {
|
1759 |
+
// Only apply LRU trimming to neutral videos; cap at 6 neutrals cached
|
1760 |
+
const MAX_NEUTRAL = 6;
|
1761 |
+
const entries = [...this._prefetchCache.entries()];
|
1762 |
+
const neutralEntries = entries.filter(([src]) => /\/neutral\//.test(src));
|
1763 |
+
if (neutralEntries.length <= MAX_NEUTRAL) return;
|
1764 |
+
// LRU heuristic: older insertion first (Map preserves insertion order)
|
1765 |
+
const excess = neutralEntries.length - MAX_NEUTRAL;
|
1766 |
+
let removed = 0;
|
1767 |
+
for (const [src, vid] of neutralEntries) {
|
1768 |
+
if (removed >= excess) break;
|
1769 |
+
// Avoid removing currently active or about to be used
|
1770 |
+
const current = this.activeVideo?.querySelector("source")?.getAttribute("src");
|
1771 |
+
if (src === current) continue;
|
1772 |
+
this._prefetchCache.delete(src);
|
1773 |
+
try {
|
1774 |
+
vid.removeAttribute("src");
|
1775 |
+
vid.load();
|
1776 |
+
} catch {}
|
1777 |
+
removed++;
|
1778 |
+
}
|
1779 |
+
} catch {}
|
1780 |
+
}
|
1781 |
+
|
1782 |
_prefetchLikely(category) {
|
1783 |
const list = this.videoCategories[category] || [];
|
1784 |
// Prefetch 1-2 next likely videos different from current
|