mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Fix performance impacts from Thumbnail Scrubber, Version Preferences and Seasonal Animations
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user