From d1b2010e09e1547bd2191e73095735b935cd85c6 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Thu, 1 Oct 2026 07:35:58 -0400 Subject: [PATCH] Fix performance impacts from Thumbnail Scrubber, Version Preferences and Seasonal Animations --- scripts/seasonal/seasonalOverlay.js | 302 +++++++++++++++++++--------- scripts/thumbnailScrubber.js | 72 +++++-- scripts/userManager.js | 7 +- scripts/versionPreferences.js | 105 ++++++++-- 4 files changed, 350 insertions(+), 136 deletions(-) diff --git a/scripts/seasonal/seasonalOverlay.js b/scripts/seasonal/seasonalOverlay.js index 812cf41..41e0bbc 100644 --- a/scripts/seasonal/seasonalOverlay.js +++ b/scripts/seasonal/seasonalOverlay.js @@ -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(); diff --git a/scripts/thumbnailScrubber.js b/scripts/thumbnailScrubber.js index 759b2f5..fb812d4 100644 --- a/scripts/thumbnailScrubber.js +++ b/scripts/thumbnailScrubber.js @@ -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)'); } diff --git a/scripts/userManager.js b/scripts/userManager.js index a278deb..4476b36 100644 --- a/scripts/userManager.js +++ b/scripts/userManager.js @@ -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(); } diff --git a/scripts/versionPreferences.js b/scripts/versionPreferences.js index 4414114..d06054e 100644 --- a/scripts/versionPreferences.js +++ b/scripts/versionPreferences.js @@ -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'); }