From 4b5002137ae94b717e23d4479e368ef90cb3eb28 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 14 Aug 2026 12:56:20 +0300 Subject: [PATCH 1/2] Honor prefers-reduced-motion automatically Readers who ask their system for reduced motion now get the same treatment the library already had behind `disableParallax: true` and `disableVideo: true`: the image is covered and positioned as usual but never moves with the scroll, and the background video is never inserted, so the poster stays on screen and no provider player is downloaded. Also pass the new video-worker host options through as `videoYoutubeHost` and `videoVimeoHost`, and pick up video-worker 3.1.0. --- CHANGELOG.md | 3 + README.md | 11 +++ package-lock.json | 8 +- package.json | 6 +- src/core.ts | 12 ++- src/defaults.ts | 4 + src/ext-video.ts | 3 + src/types.ts | 4 + src/utils/prefersReducedMotion.ts | 16 ++++ src/video-worker.d.ts | 2 + tests/reduced-motion.test.js | 122 ++++++++++++++++++++++++++++++ tests/video-dom.test.js | 20 +++++ 12 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 src/utils/prefersReducedMotion.ts create mode 100644 tests/reduced-motion.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c38cd..06ebd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- added automatic `prefers-reduced-motion` support, which stops the parallax movement and keeps the background video from loading +- added `videoYoutubeHost` and `videoVimeoHost` options, passed through to video-worker +- updated `video-worker` to 3.1.0, which fixes Vimeo backgrounds playing muted in Chrome and moves YouTube off the nocookie host - fixed `data-video-volume="0"` leaving the video unmuted, which blocked background autoplay on mobile ## [3.0.1] - Jun 9, 2026 diff --git a/README.md b/README.md index fd7291f..18826f7 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Parallax scrolling for modern browsers. Supported <img> tags, background i - [A. JavaScript way](#a-javascript-way-1) - [B. Data attribute way](#b-data-attribute-way-1) - [Options](#options) + - [Reduced motion](#reduced-motion) - [Disable on mobile devices](#disable-on-mobile-devices) - [Additional options for video extension](#additional-options-for-video-extension) - [Events](#events) @@ -418,6 +419,14 @@ jarallax(document.querySelectorAll('.jarallax'), { }); ``` +### Reduced motion + +Nothing to configure. When the reader's system asks for reduced motion, Jarallax stops on its own: the image is still covered and positioned, it just does not move with the scroll, and a background video is never loaded — the poster stays on screen instead. + +This matches what `disableParallax: true` and `disableVideo: true` already do, so the layout is the same one those paths have always produced. It is read from `(prefers-reduced-motion: reduce)` when the instance is created; changing the system setting applies on the next page load. + +Give background videos a fallback image (`imgSrc`, a `.jarallax-img` element, or a CSS `background-image`). YouTube and Vimeo backgrounds fall back to the provider thumbnail automatically, but a self-hosted video has no thumbnail to borrow. + ### Additional options for video extension Requires the video extension bundle. In package-based apps call `jarallaxVideo()` after importing from `jarallax`. In script-tag usage load `dist/jarallax-video(.min).js` after the core bundle. @@ -431,6 +440,8 @@ videoEndTime | float | `0` | End time in seconds when video will be ended. videoLoop | boolean | `true` | Loop video to play infinitely. videoPlayOnlyVisible | boolean | `true` | Play video only when it is visible on the screen. videoLazyLoading | boolean | `true` | Preload videos only when it is visible on the screen. +videoYoutubeHost | string | - | Origin the YouTube embed is loaded from, for example `https://www.youtube-nocookie.com`. Left empty, video-worker picks its own default. +videoVimeoHost | string | - | Origin the Vimeo embed is loaded from. Left empty, video-worker picks its own default. disableVideo | boolean / RegExp / function | - | Disable video load on specific user agents (using regular expression) or with function return value. The image will be set on the background. ## Events diff --git a/package-lock.json b/package-lock.json index cdbd9c4..38a6153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.1", "license": "MIT", "dependencies": { - "video-worker": "^3.0.1" + "video-worker": "^3.1.0" }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -4518,9 +4518,9 @@ } }, "node_modules/video-worker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/video-worker/-/video-worker-3.0.1.tgz", - "integrity": "sha512-J5a5E2/E3eZfIw+A/uBsEKGmxsIQR3kaw0kOiBsQKyeU8WR+9zFufu6ZFIn4QKhmsAN5Yi2TUxLmBRRQYGfPeg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/video-worker/-/video-worker-3.1.0.tgz", + "integrity": "sha512-OyhVS4mmMpopD24eECvmhsXURm5rpofDyTIRIiDfzD2JrdufFer0YaxnR4dXwWzk6k75fTBNp8cY9OuQabHfew==", "license": "MIT" }, "node_modules/vite": { diff --git a/package.json b/package.json index 76f6305..148f220 100644 --- a/package.json +++ b/package.json @@ -76,13 +76,13 @@ ], "devDependencies": { "@biomejs/biome": "^2.4.16", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^1.0.0", "@types/jquery": "^4.0.1", "@types/node": "^25.9.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.8", "clean-css": "^5.3.3", "cross-env": "^10.1.0", @@ -105,6 +105,6 @@ "react-dom": ">=18.0.0" }, "dependencies": { - "video-worker": "^3.0.1" + "video-worker": "^3.1.0" } } diff --git a/src/core.ts b/src/core.ts index ad3a284..b35d257 100644 --- a/src/core.ts +++ b/src/core.ts @@ -5,6 +5,7 @@ import extend from './utils/extend'; import getParents from './utils/getParents'; import getWindowSize from './utils/getWindowSize'; import { addObserver, removeObserver } from './utils/observer'; +import prefersReducedMotion from './utils/prefersReducedMotion'; import type { DisableOption, JarallaxCoverImageData, @@ -109,12 +110,19 @@ class Jarallax { }); this.options.speed = Math.min(2, Math.max(-1, parseFloat(`${this.options.speed}`))); - this.options.disableParallax = resolveDisableOption( + + // Readers who ask for reduced motion get the same treatment as `disableParallax: true`: + // the image is still covered and positioned, it just never moves with the scroll. + const disableParallax = resolveDisableOption( userOptions?.disableParallax ?? this.options.disableParallax ); - this.options.disableVideo = resolveDisableOption( + this.options.disableParallax = () => prefersReducedMotion() || disableParallax(); + // Same for the background video: it is never inserted, so the poster the instance already + // shows stays on screen and the provider player is never downloaded. + const disableVideo = resolveDisableOption( userOptions?.disableVideo ?? this.options.disableVideo ); + this.options.disableVideo = () => prefersReducedMotion() || disableVideo(); // `elementInViewport` historically accepts a DOM node or a jQuery-like collection. let elementInVP = this.options.elementInViewport; diff --git a/src/defaults.ts b/src/defaults.ts index 299d587..eaf6d27 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -22,6 +22,10 @@ const defaults: JarallaxOptions = { videoStartTime: 0, videoEndTime: 0, videoVolume: 0, + + // Empty means "whatever video-worker defaults to", so the host is not pinned in two places. + videoYoutubeHost: '', + videoVimeoHost: '', videoLoop: true, videoPlayOnlyVisible: true, videoLazyLoading: true, diff --git a/src/ext-video.ts b/src/ext-video.ts index a0d5e3d..68285fe 100644 --- a/src/ext-video.ts +++ b/src/ext-video.ts @@ -158,6 +158,9 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax): // `data-video-volume` reaches us as a string, and "0" is truthy. Compare the number. mute: !Number(this.options.videoVolume), volume: Number(this.options.videoVolume || 0), + // video-worker ignores undefined, so an unset host keeps its own default. + youtubeHost: this.options.videoYoutubeHost || undefined, + vimeoHost: this.options.videoVimeoHost || undefined, }); this.options.onVideoWorkerInit?.call(this, video); diff --git a/src/types.ts b/src/types.ts index acd4afb..f12ab5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,6 +57,8 @@ export interface JarallaxOptions { videoStartTime?: number | string; videoEndTime?: number | string; videoVolume?: number | string; + videoYoutubeHost?: string; + videoVimeoHost?: string; videoLoop?: boolean; videoPlayOnlyVisible?: boolean; videoLazyLoading?: boolean; @@ -93,6 +95,8 @@ export interface JarallaxResolvedOptions videoStartTime: number | string; videoEndTime: number | string; videoVolume: number | string; + videoYoutubeHost: string; + videoVimeoHost: string; videoLoop: boolean; videoPlayOnlyVisible: boolean; videoLazyLoading: boolean; diff --git a/src/utils/prefersReducedMotion.ts b/src/utils/prefersReducedMotion.ts new file mode 100644 index 0000000..6f1cbcb --- /dev/null +++ b/src/utils/prefersReducedMotion.ts @@ -0,0 +1,16 @@ +import global from './global'; + +let query: MediaQueryList | null | undefined; + +// `matches` stays live on the MediaQueryList, so the object is resolved once and read on demand. +// Resolving lazily keeps the module import-safe in SSR and in environments without matchMedia. +export default function prefersReducedMotion(): boolean { + if (typeof query === 'undefined') { + query = + typeof global.matchMedia === 'function' + ? global.matchMedia('(prefers-reduced-motion: reduce)') + : null; + } + + return query?.matches ?? false; +} diff --git a/src/video-worker.d.ts b/src/video-worker.d.ts index fbcaa73..7c79fd0 100644 --- a/src/video-worker.d.ts +++ b/src/video-worker.d.ts @@ -8,6 +8,8 @@ declare module 'video-worker' { endTime?: number; mute?: boolean; volume?: number; + youtubeHost?: string; + vimeoHost?: string; } export type VideoWorkerEvent = 'ready' | 'started' | 'ended' | 'error'; diff --git a/tests/reduced-motion.test.js b/tests/reduced-motion.test.js new file mode 100644 index 0000000..b0f0c4e --- /dev/null +++ b/tests/reduced-motion.test.js @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createJarallaxBlock } from './test-helpers.js'; + +const videoWorkerState = vi.hoisted(() => ({ + instances: [], +})); + +vi.mock('video-worker', () => { + class FakeVideoWorker { + constructor(source, options) { + this.source = source; + this.options = options; + this.handlers = new Map(); + this.type = source.includes('vimeo.com') ? 'vimeo' : 'youtube'; + this.videoID = 'mru3Q5m4lkY'; + this.videoWidth = 1280; + this.videoHeight = 720; + videoWorkerState.instances.push(this); + } + + isValid() { + return true; + } + + on(event, callback) { + this.handlers.set(event, callback); + } + + getImageURL(callback) { + callback('https://img.youtube.com/vi/mru3Q5m4lkY/maxresdefault.jpg'); + } + + getVideo(callback) { + const hidden = document.createElement('div'); + const iframe = document.createElement('iframe'); + hidden.appendChild(iframe); + document.body.appendChild(hidden); + callback(iframe); + } + + play = vi.fn(); + + pause = vi.fn(); + } + + return { default: FakeVideoWorker }; +}); + +// Only `(prefers-reduced-motion: reduce)` is answered; everything else stays false, which is +// what jsdom would report if it implemented matchMedia at all. +function stubReducedMotion(reduce) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query) => ({ + media: query, + matches: reduce && query === '(prefers-reduced-motion: reduce)', + addEventListener() {}, + removeEventListener() {}, + })) + ); +} + +describe('prefers-reduced-motion', () => { + beforeEach(() => { + vi.resetModules(); + videoWorkerState.instances.length = 0; + }); + + it.each([ + [true, false], + [false, true], + ])('reduce=%s -> parallax container created: %s', async (reduce, expectContainer) => { + stubReducedMotion(reduce); + + const { default: jarallax } = await import('../src/core.ts'); + const block = createJarallaxBlock({ mode: 'background' }); + + jarallax(block); + + expect(Boolean(block.querySelector('.jarallax-container'))).toBe(expectContainer); + expect(block.jarallax.options.disableParallax()).toBe(reduce); + }); + + it.each([ + [true, false], + [false, true], + ])('reduce=%s -> background video inserted: %s', async (reduce, expectVideo) => { + stubReducedMotion(reduce); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = createJarallaxBlock({ mode: 'img' }); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'https://youtu.be/mru3Q5m4lkY' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + expect(Boolean(block.querySelector('iframe'))).toBe(expectVideo); + expect(block.jarallax.options.disableVideo()).toBe(reduce); + }); + + it('keeps the poster visible instead of the player when motion is reduced', async () => { + stubReducedMotion(true); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = createJarallaxBlock({ mode: 'background' }); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'https://youtu.be/mru3Q5m4lkY' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + // The author's own background survives, and no provider player was ever asked for. + expect(block.jarallax.image.bgImage).toContain('https://via.placeholder.com/100x50'); + expect(block.querySelector('iframe')).toBeNull(); + expect(videoWorkerState.instances[0].play).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/video-dom.test.js b/tests/video-dom.test.js index fa62be7..eb78b3d 100644 --- a/tests/video-dom.test.js +++ b/tests/video-dom.test.js @@ -137,4 +137,24 @@ describe('jarallax video DOM integration', () => { expect(worker.options.mute).toBe(expectedMute); expect(worker.options.volume).toBe(Number(attr)); }); + + it('forwards the player host options and leaves them undefined when unset', async () => { + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = createJarallaxBlock({ mode: 'img' }); + const other = createJarallaxBlock({ mode: 'img' }); + + jarallaxVideo(jarallax); + jarallax(block, { + videoSrc: 'https://youtu.be/mru3Q5m4lkY', + videoYoutubeHost: 'https://www.youtube-nocookie.com', + }); + jarallax(other, { videoSrc: 'https://youtu.be/mru3Q5m4lkY' }); + + const [withHost, withoutHost] = videoWorkerState.instances; + + expect(withHost.options.youtubeHost).toBe('https://www.youtube-nocookie.com'); + expect(withoutHost.options.youtubeHost).toBeUndefined(); + expect(withoutHost.options.vimeoHost).toBeUndefined(); + }); }); From d534614ff74fc596bcde29da48b38b1a728931fa Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 14 Aug 2026 13:17:29 +0300 Subject: [PATCH 2/2] Show the first frame of a self-hosted background video A self-hosted video has no provider thumbnail to fall back on, so an element with no image of its own went blank under reduced motion. It is now inserted paused instead: browsers paint the first frame of a video that is not playing, as long as the placeholder poster is left off, so the block keeps something to look at. Reduced motion is no longer folded into `disableVideo`, because what replaces the video depends on the provider and that option should keep meaning only what the author asked for. --- CHANGELOG.md | 2 +- README.md | 15 +++++-- src/core.ts | 9 ++-- src/ext-video.ts | 30 ++++++++++++-- tests/reduced-motion.test.js | 80 +++++++++++++++++++++++++++++++++--- 5 files changed, 120 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ebd9f..7c47013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- added automatic `prefers-reduced-motion` support, which stops the parallax movement and keeps the background video from loading +- added automatic `prefers-reduced-motion` support, which stops the parallax movement and background video playback, falling back to the provider thumbnail, the author's image, or the video's own first frame - added `videoYoutubeHost` and `videoVimeoHost` options, passed through to video-worker - updated `video-worker` to 3.1.0, which fixes Vimeo backgrounds playing muted in Chrome and moves YouTube off the nocookie host - fixed `data-video-volume="0"` leaving the video unmuted, which blocked background autoplay on mobile diff --git a/README.md b/README.md index 18826f7..8bfb59a 100644 --- a/README.md +++ b/README.md @@ -421,11 +421,20 @@ jarallax(document.querySelectorAll('.jarallax'), { ### Reduced motion -Nothing to configure. When the reader's system asks for reduced motion, Jarallax stops on its own: the image is still covered and positioned, it just does not move with the scroll, and a background video is never loaded — the poster stays on screen instead. +Nothing to configure. When the reader's system asks for reduced motion, Jarallax stops on its own: the image is still covered and positioned, it just does not move with the scroll, and background video never plays. The block always keeps something to look at: -This matches what `disableParallax: true` and `disableVideo: true` already do, so the layout is the same one those paths have always produced. It is read from `(prefers-reduced-motion: reduce)` when the instance is created; changing the system setting applies on the next page load. +| Background | Reduced motion shows | +| :--- | :--- | +| Image | The image, positioned as usual, not moving | +| YouTube / Vimeo | The provider thumbnail; the player iframe is never requested | +| Self-hosted, with a fallback image | That image; the video is never requested | +| Self-hosted, no fallback image | The video's own first frame, inserted paused | -Give background videos a fallback image (`imgSrc`, a `.jarallax-img` element, or a CSS `background-image`). YouTube and Vimeo backgrounds fall back to the provider thumbnail automatically, but a self-hosted video has no thumbnail to borrow. +The parallax side matches what `disableParallax: true` already does, so the layout is the one that path has always produced. `disableVideo` keeps meaning only what you asked for — reduced motion is decided separately, because what replaces the video depends on the provider. + +A fallback image (`imgSrc`, a `.jarallax-img` element, or a CSS `background-image`) is still worth adding for a self-hosted video: it gives you control over the frame and it loads faster than the video does. + +Read from `(prefers-reduced-motion: reduce)` when the instance is created; changing the system setting applies on the next page load. ### Additional options for video extension diff --git a/src/core.ts b/src/core.ts index b35d257..1ac649e 100644 --- a/src/core.ts +++ b/src/core.ts @@ -117,12 +117,13 @@ class Jarallax { userOptions?.disableParallax ?? this.options.disableParallax ); this.options.disableParallax = () => prefersReducedMotion() || disableParallax(); - // Same for the background video: it is never inserted, so the poster the instance already - // shows stays on screen and the provider player is never downloaded. - const disableVideo = resolveDisableOption( + + // Reduced motion stops background video too, but what to show instead depends on the + // provider, so that decision lives in the video extension and this option keeps meaning + // only what the author asked for. + this.options.disableVideo = resolveDisableOption( userOptions?.disableVideo ?? this.options.disableVideo ); - this.options.disableVideo = () => prefersReducedMotion() || disableVideo(); // `elementInViewport` historically accepts a DOM node or a jQuery-like collection. let elementInVP = this.options.elementInViewport; diff --git a/src/ext-video.ts b/src/ext-video.ts index 68285fe..2a341c8 100644 --- a/src/ext-video.ts +++ b/src/ext-video.ts @@ -2,6 +2,16 @@ import VideoWorker from 'video-worker'; import type { JarallaxCoverImageData, JarallaxInstance, JarallaxStatic } from './types'; import global from './utils/global'; +import prefersReducedMotion from './utils/prefersReducedMotion'; + +// Under reduced motion a provider iframe buys nothing — its thumbnail is already painted as the +// background — but a self-hosted video with no fallback image is the only thing that element has +// to show, so it is inserted paused and the browser paints its first frame. +function isPosterOnlyVideo(instance: JarallaxInstance): boolean { + return ( + prefersReducedMotion() && instance.video?.type === 'local' && !instance.defaultInitImgResult + ); +} function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax): void { if (typeof jarallax === 'undefined') { @@ -15,11 +25,13 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax): Jarallax.prototype.onScroll = function onScrollWithVideo(this: JarallaxInstance): void { defOnScroll.apply(this); + const posterOnly = isPosterOnlyVideo(this); const isReady = !this.isVideoInserted && this.video && (!this.options.videoLazyLoading || this.isElementInViewport) && - !this.options.disableVideo(); + !this.options.disableVideo() && + (!prefersReducedMotion() || posterOnly); if (!isReady) { return; @@ -49,8 +61,13 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax): this.$video = insertedVideo; // Self-hosted video should keep using the image as a poster, just like the legacy extension did. + // In the poster-only case that image is the transparent placeholder, and setting it would + // cover the very frame this element was inserted to show, so it is left off and the video + // is stretched to cover by itself — `started` never fires to hand it to coverImage(). if (this.video?.type === 'local') { - if (this.image.src) { + if (posterOnly) { + this.css(insertedVideo, { objectFit: 'cover' }); + } else if (this.image.src) { insertedVideo.setAttribute('poster', this.image.src); } else if (this.image.$item?.tagName === 'IMG') { insertedVideo.setAttribute('poster', (this.image.$item as HTMLImageElement).src); @@ -149,7 +166,9 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax): } const video = new VideoWorker(this.options.videoSrc, { - autoplay: true, + // Self-hosted players autoplay themselves once metadata lands, so this is what keeps a + // poster-only video paused. + autoplay: !prefersReducedMotion(), loop: this.options.videoLoop, showControls: false, accessibilityHidden: true, @@ -209,6 +228,11 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax): } video.on('ready', () => { + // Nothing starts on its own under reduced motion, so the visibility hook is never installed. + if (prefersReducedMotion()) { + return; + } + // Visibility-driven play/pause is applied by wrapping the existing onScroll implementation. if (this.options.videoPlayOnlyVisible) { const oldOnScroll = this.onScroll; diff --git a/tests/reduced-motion.test.js b/tests/reduced-motion.test.js index b0f0c4e..8f051bf 100644 --- a/tests/reduced-motion.test.js +++ b/tests/reduced-motion.test.js @@ -11,7 +11,11 @@ vi.mock('video-worker', () => { this.source = source; this.options = options; this.handlers = new Map(); - this.type = source.includes('vimeo.com') ? 'vimeo' : 'youtube'; + this.type = source.startsWith('mp4:') + ? 'local' + : source.includes('vimeo.com') + ? 'vimeo' + : 'youtube'; this.videoID = 'mru3Q5m4lkY'; this.videoWidth = 1280; this.videoHeight = 720; @@ -32,10 +36,10 @@ vi.mock('video-worker', () => { getVideo(callback) { const hidden = document.createElement('div'); - const iframe = document.createElement('iframe'); - hidden.appendChild(iframe); + const node = document.createElement(this.type === 'local' ? 'video' : 'iframe'); + hidden.appendChild(node); document.body.appendChild(hidden); - callback(iframe); + callback(node); } play = vi.fn(); @@ -98,7 +102,8 @@ describe('prefers-reduced-motion', () => { jarallax(block, 'onScroll'); expect(Boolean(block.querySelector('iframe'))).toBe(expectVideo); - expect(block.jarallax.options.disableVideo()).toBe(reduce); + // `disableVideo` keeps reporting only what the author asked for. + expect(block.jarallax.options.disableVideo()).toBe(false); }); it('keeps the poster visible instead of the player when motion is reduced', async () => { @@ -119,4 +124,69 @@ describe('prefers-reduced-motion', () => { expect(block.querySelector('iframe')).toBeNull(); expect(videoWorkerState.instances[0].play).not.toHaveBeenCalled(); }); + + // A self-hosted video with no fallback image has nothing else to show, so it is inserted + // paused. The placeholder poster must stay off or it would hide the frame. + it('inserts a self-hosted video paused when the element has no fallback image', async () => { + stubReducedMotion(true); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = document.createElement('div'); + block.className = 'jarallax'; + document.body.appendChild(block); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'mp4:../demo/video/video.mp4' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + const inserted = block.querySelector('video'); + + expect(inserted).toBeTruthy(); + expect(inserted.getAttribute('poster')).toBeNull(); + expect(inserted.style.objectFit).toBe('cover'); + expect(videoWorkerState.instances[0].options.autoplay).toBe(false); + expect(videoWorkerState.instances[0].play).not.toHaveBeenCalled(); + }); + + it('skips the self-hosted video when the element already has a fallback image', async () => { + stubReducedMotion(true); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = createJarallaxBlock({ mode: 'background' }); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'mp4:../demo/video/video.mp4' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + expect(block.querySelector('video')).toBeNull(); + expect(block.jarallax.image.bgImage).toContain('https://via.placeholder.com/100x50'); + }); + + it('keeps autoplay and the poster when motion is not reduced', async () => { + stubReducedMotion(false); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = document.createElement('div'); + block.className = 'jarallax'; + document.body.appendChild(block); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'mp4:../demo/video/video.mp4' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + const inserted = block.querySelector('video'); + + expect(inserted).toBeTruthy(); + expect(inserted.getAttribute('poster')).toContain('data:image/gif;base64,'); + expect(videoWorkerState.instances[0].options.autoplay).toBe(true); + }); });