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
|