diff --git a/libs/blocks/carousel/carousel.js b/libs/blocks/carousel/carousel.js
index e50c5639d9a..a2c7d4d5549 100644
--- a/libs/blocks/carousel/carousel.js
+++ b/libs/blocks/carousel/carousel.js
@@ -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.
@@ -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) {
@@ -416,6 +452,7 @@ function moveSlides(event, carouselElements) {
const atBoundary = isNext ? atEnd : atStart;
if (atBoundary) {
checkCircularNav(carouselElements);
+ pauseVideosOutOfView(slides, slideContainer.closest('.carousel-wrapper'));
return;
}
}
@@ -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) {
@@ -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) {
diff --git a/test/blocks/carousel/carousel.test.js b/test/blocks/carousel/carousel.test.js
index f37a0bcc6f8..b37d63c4f6b 100644
--- a/test/blocks/carousel/carousel.test.js
+++ b/test/blocks/carousel/carousel.test.js
@@ -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: '' },
+ { h2: 'V2', body: '' },
+ ],
+ });
+ 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: '' },
+ ],
+ });
+ 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;
+ });
+});