Fix performance impacts from Thumbnail Scrubber, Version Preferences and Seasonal Animations

This commit is contained in:
HighImKevin
2026-10-01 07:35:58 -04:00
parent 199404e8a6
commit d1b2010e09
4 changed files with 350 additions and 136 deletions
+204 -98
View File
@@ -36,6 +36,14 @@ function addSeasonalOverlay() {
const FLY_WOBBLE_SPEED = { min: 0.08, max: 0.16 };
const FLY_ROT_WOBBLE = { min: 8, max: 22 };
const TARGET_FPS = 30;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
const BASE_FRAME_MS = 1000 / 60;
const MAX_DT_MS = 50;
const RESIZE_DEBOUNCE_MS = 150;
const SPRITE_BASE_SIZE = 32;
const MAX_DPR = 2;
const style = document.createElement('style');
style.textContent = `
body {
@@ -57,30 +65,64 @@ function addSeasonalOverlay() {
const canvas = document.createElement('canvas');
canvas.className = 'snowverlay';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { alpha: true });
/** CSS-pixel size used for physics / clearRect (not buffer pixels). */
let cssWidth = window.innerWidth || 1;
let cssHeight = CANVAS_HEIGHT_PX;
const buildGlyphSprite = () => {
const sprite = document.createElement('canvas');
sprite.width = SPRITE_BASE_SIZE;
sprite.height = SPRITE_BASE_SIZE;
const sCtx = sprite.getContext('2d');
sCtx.clearRect(0, 0, SPRITE_BASE_SIZE, SPRITE_BASE_SIZE);
sCtx.font = `${SPRITE_BASE_SIZE * 0.85}px sans-serif`;
sCtx.textAlign = 'center';
sCtx.textBaseline = 'middle';
sCtx.fillText(glyph, SPRITE_BASE_SIZE / 2, SPRITE_BASE_SIZE / 2);
return sprite;
};
const glyphSprite = buildGlyphSprite();
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = CANVAS_HEIGHT_PX;
cssWidth = Math.max(1, window.innerWidth || 1);
cssHeight = CANVAS_HEIGHT_PX;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
window.addEventListener('resize', resizeCanvas);
let resizeTimer = null;
const onResize = () => {
if (resizeTimer != null) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
resizeCanvas();
if (reducedMotionMq.matches && shouldPaintStatic()) {
renderStatic();
}
}, RESIZE_DEBOUNCE_MS);
};
window.addEventListener('resize', onResize);
resizeCanvas();
const particleOpacity = (particle) => {
if (particle.opacity != null) return particle.opacity;
if (!isFly) {
return Math.max(0, 1 - particle.y / canvas.height);
return Math.max(0, 1 - particle.y / cssHeight);
}
if (particle.y <= FADE_START_Y) {
return 1;
}
const fadeRange = Math.max(1, canvas.height - FADE_START_Y);
const fadeRange = Math.max(1, cssHeight - FADE_START_Y);
return Math.max(0, 1 - (particle.y - FADE_START_Y) / fadeRange);
};
const flyWindowCenterX = () => canvas.width * 0.5;
const flyWindowHalfWidth = () => Math.max(40, canvas.width * FLY_WINDOW_WIDTH_FRAC * 0.5);
const flyWindowCenterX = () => cssWidth * 0.5;
const flyWindowHalfWidth = () => Math.max(40, cssWidth * FLY_WINDOW_WIDTH_FRAC * 0.5);
/** Spawn from top-center window; side picks outward curve direction. */
const initFlyMotion = (particle, index = 0) => {
@@ -138,10 +180,10 @@ function addSeasonalOverlay() {
}
return {
x: Math.random() * canvas.width,
x: Math.random() * cssWidth,
y: isAnimated
? -20 - (index * canvas.height) / PARTICLE_AMOUNT
: Math.random() * Math.min(FADE_START_Y, canvas.height),
? -20 - (index * cssHeight) / PARTICLE_AMOUNT
: Math.random() * Math.min(FADE_START_Y, cssHeight),
size: Math.random() * (PARTICLE_SIZE.max - PARTICLE_SIZE.min) + PARTICLE_SIZE.min,
speed: Math.random() * (PARTICLE_SPEED.max - PARTICLE_SPEED.min) + PARTICLE_SPEED.min,
opacity: isAnimated ? null : Math.random() * 0.5 + 0.2,
@@ -157,7 +199,7 @@ function addSeasonalOverlay() {
return;
}
particle.y = -10 - Math.random() * 30;
particle.x = Math.random() * canvas.width;
particle.x = Math.random() * cssWidth;
particle.speed = Math.random() * (PARTICLE_SPEED.max - PARTICLE_SPEED.min) + PARTICLE_SPEED.min;
particle.drift = Math.random() * 0.4 - 0.2;
particle.rotation = Math.random() * 360;
@@ -167,110 +209,174 @@ function addSeasonalOverlay() {
const opacity = particleOpacity(particle);
if (opacity <= 0.01) return;
const size = particle.size;
ctx.save();
ctx.translate(particle.x, particle.y);
ctx.rotate((particle.rotation * Math.PI) / 180);
ctx.font = `${particle.size}px sans-serif`;
ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(glyph, 0, 0);
ctx.globalAlpha = opacity;
ctx.drawImage(glyphSprite, -size / 2, -size / 2, size, size);
ctx.restore();
};
let animationFrame;
const particles = Array.from(
{ length: PARTICLE_AMOUNT },
(_, i) => createParticle(true, i)
);
let animationFrame = null;
let lastTime = 0;
let animating = false;
const stopAnimation = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
if (animationFrame != null) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
animating = false;
};
const renderStatic = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
Array.from({ length: PARTICLE_AMOUNT }, () => createParticle(false))
.forEach(drawParticle);
};
const updateParticle = (particle, frameScale) => {
if (isFly) {
particle.vx += (particle.outwardAccel || 0) * frameScale;
const maxOut = FLY_OUTWARD_MAX;
if (Math.abs(particle.vx) > maxOut) {
particle.vx = Math.sign(particle.vx) * maxOut;
}
particle.vy = Math.min(
FLY_DOWN_MAX,
particle.vy + (particle.downAccel || 0) * frameScale
);
const startAnimation = () => {
const particles = Array.from(
{ length: PARTICLE_AMOUNT },
(_, i) => createParticle(true, i)
);
particle.wobblePhase += particle.wobbleSpeed * frameScale;
particle.wobblePhase2 += particle.wobbleSpeed2 * frameScale;
const wobbleX =
Math.sin(particle.wobblePhase) * particle.wobbleAmp +
Math.sin(particle.wobblePhase2) * particle.wobbleAmp2;
const wobbleY =
Math.cos(particle.wobblePhase * 0.85) * (particle.wobbleAmp * 0.35) +
Math.sin(particle.wobblePhase2 * 1.3) * (particle.wobbleAmp2 * 0.5);
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particle.x += (particle.vx + wobbleX * 0.15) * frameScale;
particle.y += (particle.vy + wobbleY * 0.12) * frameScale;
particles.forEach((particle) => {
if (isFly) {
// Accelerate outward + downward (leaving the window)
particle.vx += particle.outwardAccel || 0;
const maxOut = FLY_OUTWARD_MAX;
if (Math.abs(particle.vx) > maxOut) {
particle.vx = Math.sign(particle.vx) * maxOut;
}
particle.vy = Math.min(FLY_DOWN_MAX, particle.vy + (particle.downAccel || 0));
particle.wobblePhase += particle.wobbleSpeed;
particle.wobblePhase2 += particle.wobbleSpeed2;
const wobbleX =
Math.sin(particle.wobblePhase) * particle.wobbleAmp +
Math.sin(particle.wobblePhase2) * particle.wobbleAmp2;
const wobbleY =
Math.cos(particle.wobblePhase * 0.85) * (particle.wobbleAmp * 0.35) +
Math.sin(particle.wobblePhase2 * 1.3) * (particle.wobbleAmp2 * 0.5);
particle.x += particle.vx + wobbleX * 0.15;
particle.y += particle.vy + wobbleY * 0.12;
// Face along velocity with organic rotational flutter
const aimDeg = (Math.atan2(particle.vy, particle.vx) * 180) / Math.PI + 90;
const rotFlutter = Math.sin(particle.wobblePhase) * particle.rotWobbleAmp
+ Math.sin(particle.wobblePhase2) * (particle.rotWobbleAmp * 0.4);
particle.rotation = aimDeg + rotFlutter + particle.rotationSpeed;
} else {
particle.y += particle.speed;
particle.x += particle.drift;
particle.rotation += particle.rotationSpeed;
}
const offBottom = particle.y > canvas.height + 20;
const fadedOut = isFly && particle.y > FADE_START_Y && particleOpacity(particle) <= 0.02;
const offSides = particle.x < -50 || particle.x > canvas.width + 50;
if (offBottom || fadedOut || offSides) {
resetParticle(particle);
}
drawParticle(particle);
});
animationFrame = requestAnimationFrame(animate);
};
animate();
return stopAnimation;
};
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let currentAnimation = null;
const handleMotionChange = (e) => {
stopAnimation();
if (currentAnimation) currentAnimation();
if (e.matches) {
renderStatic();
currentAnimation = null;
const aimDeg = (Math.atan2(particle.vy, particle.vx) * 180) / Math.PI + 90;
const rotFlutter = Math.sin(particle.wobblePhase) * particle.rotWobbleAmp
+ Math.sin(particle.wobblePhase2) * (particle.rotWobbleAmp * 0.4);
particle.rotation = aimDeg + rotFlutter + particle.rotationSpeed * frameScale;
} else {
currentAnimation = startAnimation();
particle.y += particle.speed * frameScale;
particle.x += particle.drift * frameScale;
particle.rotation += particle.rotationSpeed * frameScale;
}
const offBottom = particle.y > cssHeight + 20;
const fadedOut = isFly && particle.y > FADE_START_Y && particleOpacity(particle) <= 0.02;
const offSides = particle.x < -50 || particle.x > cssWidth + 50;
if (offBottom || fadedOut || offSides) {
resetParticle(particle);
}
};
mediaQuery.addEventListener('change', handleMotionChange);
handleMotionChange(mediaQuery);
const animate = (currentTime) => {
animationFrame = requestAnimationFrame(animate);
if (!lastTime) {
lastTime = currentTime;
return;
}
const rawDelta = currentTime - lastTime;
if (rawDelta < FRAME_INTERVAL) return;
lastTime = currentTime - (rawDelta % FRAME_INTERVAL);
const dt = Math.min(rawDelta, MAX_DT_MS);
const frameScale = dt / BASE_FRAME_MS;
ctx.clearRect(0, 0, cssWidth, cssHeight);
for (let i = 0; i < particles.length; i++) {
const particle = particles[i];
updateParticle(particle, frameScale);
drawParticle(particle);
}
};
const renderStatic = () => {
ctx.clearRect(0, 0, cssWidth, cssHeight);
for (let i = 0; i < PARTICLE_AMOUNT; i++) {
drawParticle(createParticle(false));
}
};
const reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
const mobileMq = window.matchMedia('(max-width: 899px)');
const isHomePageActive = () =>
document.documentElement.getAttribute('data-active-page') === 'home';
const shouldRunAnimation = () =>
!document.hidden
&& isHomePageActive()
&& !mobileMq.matches
&& !reducedMotionMq.matches;
const shouldPaintStatic = () =>
!document.hidden
&& isHomePageActive()
&& !mobileMq.matches
&& reducedMotionMq.matches;
const startAnimation = () => {
if (animating) return;
animating = true;
lastTime = 0;
animationFrame = requestAnimationFrame(animate);
};
const syncAnimationRunning = () => {
if (shouldRunAnimation()) {
if (!animating) startAnimation();
return;
}
window.addEventListener('beforeunload', () => {
mediaQuery.removeEventListener('change', handleMotionChange);
stopAnimation();
});
if (shouldPaintStatic()) {
renderStatic();
} else {
ctx.clearRect(0, 0, cssWidth, cssHeight);
}
};
const onVisibilityChange = () => syncAnimationRunning();
const onReducedMotionChange = () => syncAnimationRunning();
const onMobileChange = () => syncAnimationRunning();
document.addEventListener('visibilitychange', onVisibilityChange);
reducedMotionMq.addEventListener('change', onReducedMotionChange);
mobileMq.addEventListener('change', onMobileChange);
let pageAttrObserver = null;
if (typeof MutationObserver !== 'undefined') {
pageAttrObserver = new MutationObserver(() => syncAnimationRunning());
pageAttrObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-active-page']
});
}
syncAnimationRunning();
const cleanup = () => {
stopAnimation();
if (resizeTimer != null) clearTimeout(resizeTimer);
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibilityChange);
reducedMotionMq.removeEventListener('change', onReducedMotionChange);
mobileMq.removeEventListener('change', onMobileChange);
if (pageAttrObserver) pageAttrObserver.disconnect();
};
window.addEventListener('beforeunload', cleanup);
}
addSeasonalOverlay();
+53 -19
View File
@@ -44,6 +44,35 @@
let hoverPreviewRafId = null;
let hoverPreviewIntervalId = null;
/** True while the user is actively scrolling; blocks scrubber attach. */
let isViewportScrolling = false;
let scrollIdleTimer = null;
const SCROLL_IDLE_MS = 150;
function markViewportScrolling() {
isViewportScrolling = true;
if (scrollIdleTimer) clearTimeout(scrollIdleTimer);
scrollIdleTimer = setTimeout(() => {
scrollIdleTimer = null;
isViewportScrolling = false;
}, SCROLL_IDLE_MS);
}
function setScrubberVisible(card, visible) {
if (!card?.classList) return;
card.classList.toggle('kefin-scrubber-visible', !!visible);
if (!visible) card.classList.remove('kefin-scrubber-zone-active');
}
function setScrubberZoneActive(card, active) {
if (!card?.classList) return;
card.classList.toggle('kefin-scrubber-zone-active', !!active);
}
function isScrubberZoneActive(card) {
return !!card?.classList?.contains('kefin-scrubber-zone-active');
}
if (!document.getElementById('kefin-scrubber-styles')) {
const style = document.createElement('style');
style.id = 'kefin-scrubber-styles';
@@ -54,8 +83,8 @@
pointer-events: none;
opacity: 0; transition: opacity 0.15s ease;
}
.kefin-scrubber-overlay.is-visible { opacity: 1; }
.kefin-scrubber-overlay.is-zone-active {
.card.kefin-scrubber-visible .kefin-scrubber-overlay { opacity: 1; }
.card.kefin-scrubber-zone-active .kefin-scrubber-overlay {
pointer-events: auto;
}
.kefin-scrubber-progress-wrap {
@@ -65,7 +94,7 @@
position: relative;
z-index: 20;
}
.kefin-scrubber-overlay.is-zone-active .kefin-scrubber-progress-wrap {
.card.kefin-scrubber-zone-active .kefin-scrubber-progress-wrap {
transform: translateY(-8px);
padding-bottom: 8px;
pointer-events: auto;
@@ -92,7 +121,7 @@
z-index: 10;
transition: transform 0.2s ease;
}
.kefin-scrubber-overlay.is-zone-active .kefin-scrubber-preview {
.card.kefin-scrubber-zone-active .kefin-scrubber-preview {
display: flex;
}
.kefin-scrubber-overlay.is-popover-open .kefin-scrubber-preview {
@@ -128,14 +157,14 @@
background: rgba(0,0,0,0.85); border-radius: 4px;
color: #fff; font-size: 13px; font-weight: 500;
}
.card:has(.kefin-scrubber-overlay.is-visible.is-zone-active) .cardOverlayButton-br {
bottom: 30px;
.card.kefin-scrubber-host .cardOverlayButton-br {
transition: bottom 200ms ease;
}
.card:has(.kefin-scrubber-overlay.is-visible) .cardOverlayButton-br {
.card.kefin-scrubber-visible .cardOverlayButton-br {
bottom: 10px;
}
.card:has(.kefin-scrubber-overlay) .cardOverlayButton-br {
transition: bottom 200ms ease;
.card.kefin-scrubber-visible.kefin-scrubber-zone-active .cardOverlayButton-br {
bottom: 30px;
}
.kefin-scrubber-popover-option {
display: block;
@@ -837,7 +866,7 @@
function collapseZone() {
if (currentCardState && currentCardState.overlay) {
const overlay = currentCardState.overlay;
overlay.classList.remove('is-zone-active');
setScrubberZoneActive(currentCardState.card, false);
if (overlay._progressFill) overlay._progressFill.style.width = '0%';
}
}
@@ -960,7 +989,8 @@
stopHoverPreview();
if (state.overlay) {
const overlay = state.overlay;
overlay.classList.remove('is-visible', 'is-zone-active', 'is-popover-open');
setScrubberVisible(state.card, false);
overlay.classList.remove('is-popover-open');
if (overlay._progressFill) overlay._progressFill.style.width = '0%';
overlay._popoverOpen = false;
if (overlay._popover) overlay._popover.style.display = 'none';
@@ -1187,7 +1217,7 @@
if (!data) {
if (state.overlay) {
state.overlay.setAttribute('data-no-trickplay', 'true');
state.overlay.classList.remove('is-visible', 'is-zone-active');
setScrubberVisible(state.card, false);
}
return;
}
@@ -1250,9 +1280,11 @@
function attachCard(ctx) {
if (!ctx) return;
if (isViewportScrolling) return;
const overlay = getOrCreateScrubberOverlay(ctx.cardImageContainer);
const noTrickplay = overlay.hasAttribute('data-no-trickplay');
const card = ctx.card;
card.classList.add('kefin-scrubber-host');
function handleCardMouseLeave() {
teardown();
}
@@ -1285,7 +1317,7 @@
};
// Scrub overlay stays hidden when there is no trickplay; video hover still attaches.
if (!noTrickplay) {
overlay.classList.add('is-visible');
setScrubberVisible(card, true);
}
if (isHoverPreviewEnabled()) {
maybeStartHoverPreview();
@@ -1317,10 +1349,10 @@
if (!data) {
LOG('activateScrubber: no data, abort', itemId);
overlay.setAttribute('data-no-trickplay', 'true');
overlay.classList.remove('is-visible', 'is-zone-active');
setScrubberVisible(currentCardState.card, false);
return;
}
overlay.classList.add('is-zone-active');
setScrubberZoneActive(currentCardState.card, true);
currentCardState.scrubberData = data;
updateUI(currentCardState, currentCardState.lastClientX, currentCardState.lastClientY);
}
@@ -1338,6 +1370,7 @@
}
function onDelegatedMouseOver(e) {
if (isViewportScrolling) return;
if (!e.target?.closest?.('.itemsContainer, .detailImageContainer')) return;
const ctx = resolveCardFromElement(e.target);
if (!ctx) return;
@@ -1357,7 +1390,7 @@
clearActivationTimer();
const overlay = state.overlay;
if (overlay && !overlay._popoverOpen) {
overlay.classList.remove('is-zone-active');
setScrubberZoneActive(card, false);
if (overlay._progressFill) overlay._progressFill.style.width = '0%';
}
return;
@@ -1381,7 +1414,7 @@
scheduleUIUpdate();
} else {
if (!overlay._popoverOpen) {
overlay.classList.remove('is-zone-active');
setScrubberZoneActive(card, false);
if (overlay._progressFill) overlay._progressFill.style.width = '0%';
}
if (!overlay._popoverOpen) clearActivationTimer();
@@ -1406,7 +1439,7 @@
return;
}
if (!overlay.classList.contains('is-zone-active')) return;
if (!isScrubberZoneActive(currentCardState.card)) return;
if (overlay._popover && overlay._popover.contains(e.target)) return;
if (!overlay.contains(e.target)) return;
if (!isInScrubZone(currentCardState.cardImageContainer, e.clientY)) return;
@@ -1421,7 +1454,7 @@
data = await getScrubberData(itemId);
if (!data) {
overlay.setAttribute('data-no-trickplay', 'true');
overlay.classList.remove('is-visible', 'is-zone-active');
setScrubberVisible(clickCard, false);
return;
}
currentCardState.scrubberData = data;
@@ -1476,6 +1509,7 @@
function init() {
const root = getDelegationRoot();
root.addEventListener('mouseover', onDelegatedMouseOver, false);
window.addEventListener('scroll', markViewportScrolling, { passive: true, capture: true });
LOG('Initialized (scoped enter + card-local move/click)');
}
+2 -5
View File
@@ -480,12 +480,9 @@
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
showCloseButton: true,
dialogStyle: { maxWidth: '720px', width: '90vw' }
});
if (modal?.dialog) {
modal.dialog.style.maxWidth = '720px';
modal.dialog.style.width = '90vw';
}
syncApplyEnabled();
}
+91 -14
View File
@@ -11,8 +11,10 @@
const DEFAULT_VERSIONS = ['4k', '2160p', '1080p', '720p', '576p', '480p', '360p', NONE_SENTINEL];
const DEFAULT_EDITIONS = ['Theatrical', "Director's Cut", 'Extended Edition', 'Fan Edit', NONE_SENTINEL];
const SELECTOR = '.itemDetailPage:not(.hide) .selectSourceContainer select.detailTrackSelect';
const CONTAINER_SELECTOR = '.itemDetailPage:not(.hide) .selectSourceContainer';
const PAGE_SELECTOR = '#itemDetailPage:not(.hide)';
const FORM_SELECTOR = 'form.trackSelections';
const SELECTOR = `${PAGE_SELECTOR} ${FORM_SELECTOR} select.detailTrackSelect`;
const SELECT_FALLBACK = `${PAGE_SELECTOR} select.detailTrackSelect`;
const ORIGINAL_ATTR = 'data-kefin-original-label';
const SELECTED_INDEX_ATTR = 'data-selected-index';
const CHANGE_BOUND_ATTR = 'data-kefin-vp-change-bound';
@@ -232,10 +234,23 @@
}
function findSourceSelect() {
return document.querySelector(SELECTOR);
return document.querySelector(SELECTOR) || document.querySelector(SELECT_FALLBACK);
}
function findDetailsPage() {
return document.querySelector(PAGE_SELECTOR);
}
function findTrackSelectionsForm(page) {
const root = page || findDetailsPage();
return root?.querySelector?.(FORM_SELECTOR) || null;
}
let observer = null;
/** @type {Element|null} */
let observedRoot = null;
/** True while observing the details page until form.trackSelections mounts. */
let waitingForTrackForm = false;
let applyScheduled = false;
let isApplying = false;
@@ -244,6 +259,8 @@
observer.disconnect();
observer = null;
}
observedRoot = null;
waitingForTrackForm = false;
applyScheduled = false;
}
@@ -280,42 +297,90 @@
if (node.nodeType !== 1) continue;
if (node.matches?.('select.detailTrackSelect')) return true;
if (node.matches?.('option') && !node.hasAttribute(ORIGINAL_ATTR)) return true;
if (node.querySelector?.('select.detailTrackSelect, option')) return true;
if (node.matches?.(FORM_SELECTOR)) return true;
if (node.querySelector?.('select.detailTrackSelect, option, form.trackSelections')) return true;
}
}
return false;
}
/** Long-lived document observer; gates on unbound visible details select. */
function ensureDocumentObserver() {
if (observer) return;
function mutationIntroducesTrackForm(mutations) {
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches?.(FORM_SELECTOR)) return true;
if (node.querySelector?.(FORM_SELECTOR)) return true;
}
}
return false;
}
tryApply();
const root = document.body || document.documentElement;
/**
* Observe a narrow root (prefer form.trackSelections; fall back to visible details page).
* If observing the page and the form appears, promote to the form.
*/
function observeRoot(root, pendingForm) {
if (!root) return;
disconnectObserver();
waitingForTrackForm = !!pendingForm;
observedRoot = root;
observer = new MutationObserver((mutations) => {
if (isApplying) return;
if (waitingForTrackForm) {
const form =
(mutationIntroducesTrackForm(mutations) && findTrackSelectionsForm(observedRoot)) ||
findTrackSelectionsForm(observedRoot);
if (form) {
observeRoot(form, false);
tryApply();
return;
}
}
if (!isRelevantMutation(mutations)) return;
scheduleTryApply();
});
observer.observe(root, { childList: true, subtree: true });
}
function onDetailsPage() {
ensureDocumentObserver();
function attachDetailsObserver() {
disconnectObserver();
const page = findDetailsPage();
if (!page) return;
const form = findTrackSelectionsForm(page);
if (form) {
observeRoot(form, false);
} else {
observeRoot(page, true);
}
tryApply();
}
function onDetailsPage() {
attachDetailsObserver();
}
function onAnyPage(view, _doc, hash) {
if (isDetailsHash(hash) || (typeof view === 'string' && /details/i.test(view))) {
return;
}
disconnectObserver();
}
function init() {
if (!window.KefinTweaksUtils?.onViewPage) {
WARN('KefinTweaksUtils.onViewPage unavailable');
return;
}
ensureDocumentObserver();
window.KefinTweaksUtils.onViewPage(() => {
try {
onDetailsPage();
@@ -324,6 +389,18 @@
}
}, { pages: ['details'] });
window.KefinTweaksUtils.onViewPage((view, doc, hash) => {
try {
onAnyPage(view, doc, hash);
} catch (err) {
WARN('Failed to clean up version preferences observer', err);
}
});
if (isDetailsHash()) {
attachDetailsObserver();
}
LOG('Initialized');
}