Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 55 additions & 23 deletions libs/blocks/carousel/carousel.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,14 +306,57 @@ function handleLightboxButtons(lightboxBtns, el, slideWrapper) {
});
}

function checkSlideForVideo(activeSlide) {
const video = activeSlide.querySelector('video');
/* c8 ignore start */
if (video?.played.length > 0 && !video?.paused) {
video.pause();
syncPausePlayIcon(video);
}
/* c8 ignore end */
// Hinting layouts intentionally peek the neighboring slide, so require
// majority overlap (not just any overlap) to count a slide as still "in view".
const VISIBILITY_THRESHOLD = 0.5;

function getOverlapRatio(rect, wrapperRect) {
const overlapWidth = Math.max(0, Math.min(rect.right, wrapperRect.right)
- Math.max(rect.left, wrapperRect.left));
const overlapHeight = Math.max(0, Math.min(rect.bottom, wrapperRect.bottom)
- Math.max(rect.top, wrapperRect.top));
const rectArea = rect.width * rect.height;
if (!rectArea) return 0;
return (overlapWidth * overlapHeight) / rectArea;
}

// Pauses a slide's video if it's actually playing and no longer
// sufficiently overlaps the carousel wrapper's visible (overflow: hidden) area.
function checkSlideForVideo(slide, wrapperRect) {
const video = slide.querySelector('video');
if (!video || !video.played.length || video.paused) return;
if (getOverlapRatio(video.getBoundingClientRect(), wrapperRect) >= VISIBILITY_THRESHOLD) return;
video.pause();
syncPausePlayIcon(video);
}

function pauseVideosOutOfView(slides, wrapper) {
const wrapperRect = wrapper?.getBoundingClientRect();
if (!wrapperRect) return;
slides.forEach((slide) => checkSlideForVideo(slide, wrapperRect));
}

// Waits for the slide track's transform transition to finish (falling back to
// a timer in case transitionend never fires) before checking visibility,
// since slide positions aren't final until the transition settles. Filtered
// to the track's own transform so a child slide's opacity transition
// (carousel.css .carousel-slide) can't resolve this early via bubbling.
const TRANSITION_FALLBACK_MS = 700; // .6s CSS transition + 25ms reflow delay, plus margin

function waitForSlideTransition(slideContainer, onDone) {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
// eslint-disable-next-line no-use-before-define
clearTimeout(fallbackTimer);
onDone();
};
const fallbackTimer = setTimeout(settle, TRANSITION_FALLBACK_MS);
slideContainer.addEventListener('transitionend', (e) => {
if (e.target !== slideContainer || e.propertyName !== 'transform') return;
settle();
}, { once: true });
}

// Sets a multiplier variable, used by CSS, to move the indicator dots.
Expand Down Expand Up @@ -382,13 +425,6 @@ function setAriaHiddenAndTabIndex({ el: block, slides }, activeEl) {
});
});
}
// hinting (tablet/mobile)
function isSlideVisible(currentIdx, targetIdx, n, isNext) {
if (isNext) return currentIdx === targetIdx || currentIdx === (targetIdx + 1) % n;
const prev = (targetIdx - 1 + n) % n;
const next = (targetIdx + 1) % n;
return currentIdx === prev || currentIdx === targetIdx || currentIdx === next;
}

// Skip focus on touch events to prevent the browser from scrolling the page
function focusNavButton(button, event) {
Expand Down Expand Up @@ -416,6 +452,7 @@ function moveSlides(event, carouselElements) {
const atBoundary = isNext ? atEnd : atStart;
if (atBoundary) {
checkCircularNav(carouselElements);
pauseVideosOutOfView(slides, slideContainer.closest('.carousel-wrapper'));
return;
}
}
Expand All @@ -425,17 +462,9 @@ function moveSlides(event, carouselElements) {
let referenceSlide = slideContainer.querySelector('.reference-slide');
let activeSlide = slideContainer.querySelector('.active');
let activeSlideIndicator = controlsContainer.querySelector('.active');
let skipPause = false;

// hinting-tablet / hinting-mobile
const isHintingMobile = (el.classList.contains('hinting-mobile') || el.classList.contains('hinting-center-mobile')) && isMobileVp.matches;
if (isHintingTablet(el) || isHintingMobile) {
const n = slides.length;
const currentIdx = carouselElements.currentActiveIndex;
const targetIdx = isNext ? (currentIdx + 1) % n : (currentIdx - 1 + n) % n;
skipPause = isSlideVisible(currentIdx, targetIdx, n, isNext);
}
if (!skipPause) checkSlideForVideo(activeSlide);

// Track reference slide - last slide initially
if (!referenceSlide) {
Expand Down Expand Up @@ -527,6 +556,9 @@ function moveSlides(event, carouselElements) {
const slideDelay = 25;
slideContainer.classList.remove('is-ready');
setTimeout(() => slideContainer.classList.add('is-ready'), slideDelay);

const wrapper = slideContainer.closest('.carousel-wrapper');
waitForSlideTransition(slideContainer, () => pauseVideosOutOfView(slides, wrapper));
}

export function getSwipeDistance(start, end) {
Expand Down
161 changes: 161 additions & 0 deletions test/blocks/carousel/carousel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,164 @@ describe('carousel disable-buttons deferred heights on desktop', () => {
});
});
});

/**
* carousel.js doesn't load carousel.css, so slides have no real flex/order
* layout in this environment. getBoundingClientRect and video playback state
* are stubbed directly to exercise the visibility-ratio pause logic
* deterministically, the same way img dimensions are stubbed above.
*/
function stubPlayingVideo(video, { playedLength = 1, paused = false } = {}) {
let isPaused = paused;
Object.defineProperty(video, 'played', { configurable: true, get: () => ({ length: playedLength }) });
Object.defineProperty(video, 'paused', { configurable: true, get: () => isPaused });
video.pause = () => { isPaused = true; };
return video;
}

function stubRect(el, { left = 0, right = 0, top = 0, bottom = 0 }) {
el.getBoundingClientRect = () => ({
left, right, top, bottom, width: right - left, height: bottom - top,
});
}

const WRAPPER_RECT = { left: 0, right: 100, top: 0, bottom: 100 };

// Waits past moveSlides' TRANSITION_FALLBACK_MS (700ms); no carousel.css means
// the slide track's transitionend never fires, so every move settles via the
// fallback timer rather than the real transition.
async function waitForMoveSettle() {
await new Promise((resolve) => { setTimeout(resolve, 800); });
}

describe('carousel video pause on navigation (visibility ratio)', () => {
let fixtureRoot;

beforeEach(() => {
const html = multiSectionCarouselFixture({
carouselClass: 'visibility-isolated',
blockTitle: 'Visibility isolated',
companions: [
{ h2: 'V1', body: '<video></video>' },
{ h2: 'V2', body: '<video></video>' },
],
});
fixtureRoot = appendCarouselFixture(html);
init(fixtureRoot.querySelector('.carousel'));
});

afterEach(() => {
fixtureRoot?.remove();
});

it('pauses a playing video once it drops below the visibility threshold after navigating away', async () => {
const el = fixtureRoot.querySelector('.visibility-isolated');
const wrapper = el.querySelector('.carousel-wrapper');
const slides = el.querySelectorAll('.carousel-slide');
const outgoingVideo = stubPlayingVideo(slides[0].querySelector('video'));

stubRect(wrapper, WRAPPER_RECT);
// Fully outside the wrapper's [0, 100] bounds: 0% overlap.
stubRect(outgoingVideo, { left: 200, right: 300, top: 0, bottom: 100 });

el.querySelector('.carousel-next').click();
await waitForMoveSettle();

expect(outgoingVideo.paused).to.be.true;
});

it('keeps a playing video unpaused when it still meets the visibility threshold', async () => {
const el = fixtureRoot.querySelector('.visibility-isolated');
const wrapper = el.querySelector('.carousel-wrapper');
const slides = el.querySelectorAll('.carousel-slide');
const outgoingVideo = stubPlayingVideo(slides[0].querySelector('video'));

stubRect(wrapper, WRAPPER_RECT);
// 70% of the video's own 100x100 area overlaps the wrapper.
stubRect(outgoingVideo, { left: 30, right: 130, top: 0, bottom: 100 });

el.querySelector('.carousel-next').click();
await waitForMoveSettle();

expect(outgoingVideo.paused).to.be.false;
});

it('pauses a video sitting just under the majority-visibility threshold', async () => {
const el = fixtureRoot.querySelector('.visibility-isolated');
const wrapper = el.querySelector('.carousel-wrapper');
const slides = el.querySelectorAll('.carousel-slide');
const outgoingVideo = stubPlayingVideo(slides[0].querySelector('video'));

stubRect(wrapper, WRAPPER_RECT);
// 40% overlap: below the 0.5 threshold, should pause.
stubRect(outgoingVideo, { left: 60, right: 160, top: 0, bottom: 100 });

el.querySelector('.carousel-next').click();
await waitForMoveSettle();

expect(outgoingVideo.paused).to.be.true;
});

it('does not touch a video that was never played', async () => {
const el = fixtureRoot.querySelector('.visibility-isolated');
const wrapper = el.querySelector('.carousel-wrapper');
const slides = el.querySelectorAll('.carousel-slide');
const outgoingVideo = stubPlayingVideo(slides[0].querySelector('video'), { playedLength: 0 });
let pauseCalled = false;
outgoingVideo.pause = () => { pauseCalled = true; };

stubRect(wrapper, WRAPPER_RECT);
stubRect(outgoingVideo, { left: 200, right: 300, top: 0, bottom: 100 });

el.querySelector('.carousel-next').click();
await waitForMoveSettle();

expect(pauseCalled).to.be.false;
});
});

describe('carousel video pause at disable-circular-nav boundary', () => {
let fixtureRoot;

beforeEach(() => {
const html = multiSectionCarouselFixture({
carouselClass: 'disable-circular-nav boundary-video-isolated',
blockTitle: 'Boundary video isolated',
companions: [
{ h2: 'B1' },
{ h2: 'B2', body: '<video></video>' },
],
});
fixtureRoot = appendCarouselFixture(html);
init(fixtureRoot.querySelector('.carousel'));
});

afterEach(() => {
fixtureRoot?.remove();
});

it('pauses an out-of-view playing video even when next is a blocked no-op at the boundary', () => {
const el = fixtureRoot.querySelector('.boundary-video-isolated');
const wrapper = el.querySelector('.carousel-wrapper');
const slides = el.querySelectorAll('.carousel-slide');
const next = el.querySelector('.carousel-next');

// Move to the last slide first; this is a real move, not the blocked boundary case.
next.click();
expect(slides[1].classList.contains('active')).to.be.true;
expect(next.disabled).to.be.true;

const video = stubPlayingVideo(slides[1].querySelector('video'));
stubRect(wrapper, WRAPPER_RECT);
stubRect(video, { left: 200, right: 300, top: 0, bottom: 100 });

// next is disabled, so a real .click() is suppressed by the browser
// before it ever reaches the listener. dispatchEvent bypasses that,
// invoking moveSlides directly so it takes the early-return boundary
// path - this is what carousel.js's disable-circular-nav check calls
// pauseVideosOutOfView from, with no transition to wait for.
next.dispatchEvent(new MouseEvent('click', { bubbles: true }));

expect(video.paused).to.be.true;
});
});
Loading