From 364925d9687b4cd9f01dbb428fc56962693aca1a Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Tue, 31 Mar 2026 12:03:48 +0300
Subject: [PATCH 01/10] feat: lazy-loading player
* Load player on-click
* Load player on-scroll
---
docs/es-modules/index.html | 3 +-
docs/es-modules/lazy-player.html | 114 ++++++++++++++
docs/index.html | 3 +-
docs/lazy-player.html | 117 ++++++++++++++
src/assets/styles/main.scss | 5 +
src/config/configSchema.json | 16 ++
src/utils/bootstrap-poster-url.js | 13 ++
src/utils/get-analytics-player-options.js | 4 +
src/utils/lazy-player.js | 180 ++++++++++++++++++++++
src/utils/player-api.js | 12 +-
src/utils/schedule.js | 80 ++--------
src/validators/validators.js | 1 +
src/video-player.const.js | 1 +
test/unit/bootstrap-poster-url.test.js | 25 +++
test/unit/lazy-bootstrap.test.js | 85 ++++++++++
test/unit/lazy-placeholder.test.js | 68 ++++++++
test/unit/schedule-bootstrap.test.js | 68 --------
types/cld-video-player.d.ts | 6 +
18 files changed, 661 insertions(+), 140 deletions(-)
create mode 100644 docs/es-modules/lazy-player.html
create mode 100644 docs/lazy-player.html
create mode 100644 src/utils/bootstrap-poster-url.js
create mode 100644 src/utils/lazy-player.js
create mode 100644 test/unit/bootstrap-poster-url.test.js
create mode 100644 test/unit/lazy-bootstrap.test.js
create mode 100644 test/unit/lazy-placeholder.test.js
delete mode 100644 test/unit/schedule-bootstrap.test.js
diff --git a/docs/es-modules/index.html b/docs/es-modules/index.html
index e6fe80749..2e229b94f 100644
--- a/docs/es-modules/index.html
+++ b/docs/es-modules/index.html
@@ -64,6 +64,7 @@ Code examples:
Force HLS Subtitles
HDR
Interaction Area
+ Lazy player
Multiple Players
Playlist
Playlist by Tag
@@ -71,7 +72,7 @@ Code examples:
Profiles
Raw URL
Recommendations
- Schedule (weekly time slots)
+ Schedule
Seek Thumbnails
Share & Download
Shoppable Videos
diff --git a/docs/es-modules/lazy-player.html b/docs/es-modules/lazy-player.html
new file mode 100644
index 000000000..5b2dc67c0
--- /dev/null
+++ b/docs/es-modules/lazy-player.html
@@ -0,0 +1,114 @@
+
+
+
+
+ Cloudinary Video Player - Lazy player (ESM)
+
+
+
+
+
+
+
+
Cloudinary Video Player
+
Lazy player
+
+
+
+
+
+ Click the player or the button above to initialize. The button calls player.loadPlayer().
+
+
+
Scroll to load
+
+ Scroll down - the player loads automatically when it enters the viewport.
+
+
+
+ ↓ ↓ ↓ ↓ ↓ ↓ ↓
+
+
+
+
+
Example code:
+
+import { player as createPlayer } from 'cloudinary-video-player/lazy';
+import 'cloudinary-video-player/cld-video-player.min.css';
+
+const poster = 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg';
+
+// Click to load
+const player = await createPlayer('my-video', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ poster,
+ lazy: true
+});
+// await player.loadPlayer();
+
+// Scroll to load
+createPlayer('my-video', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ poster,
+ lazy: { loadOnScroll: true }
+});
+
+
+
+
+
+
+
+
diff --git a/docs/index.html b/docs/index.html
index 242a58e4c..cbb25bce9 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -68,6 +68,7 @@ Some code examples:
Force HLS Subtitles
HDR
Interaction Area
+ Lazy player (poster until click)
Live Streaming
Multiple Players
Playlist
@@ -76,7 +77,7 @@ Some code examples:
Profiles
Raw URL
Recommendations
- Schedule (weekly time slots)
+ Schedule
Seek Thumbnails
Share & Download
Shoppable Videos
diff --git a/docs/lazy-player.html b/docs/lazy-player.html
new file mode 100644
index 000000000..934e3018a
--- /dev/null
+++ b/docs/lazy-player.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+ Cloudinary Video Player - Lazy player
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Cloudinary Video Player
+
Lazy player
+
+
+
+
+
+ Click the player or the button above to initialize. The button calls player.loadPlayer().
+
+
+
Scroll to load
+
+ Scroll down - the player loads automatically when it enters the viewport.
+
+
+
+ ↓ ↓ ↓ ↓ ↓ ↓ ↓
+
+
+
+
+
Example code:
+
+
+ const poster = 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg';
+
+ // Click to load
+ const player = await cloudinary.player('my-video', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ poster: poster,
+ lazy: true
+ });
+ // await player.loadPlayer();
+
+ // Scroll to load
+ cloudinary.player('my-video', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ poster: poster,
+ lazy: { loadOnScroll: true }
+ });
+
+
+
+
+
diff --git a/src/assets/styles/main.scss b/src/assets/styles/main.scss
index 5534680ca..9a470bbb4 100644
--- a/src/assets/styles/main.scss
+++ b/src/assets/styles/main.scss
@@ -76,6 +76,11 @@ $icon-font-path: '../icon-font' !default;
text-shadow: none;
}
+ &.cld-lazy-preactivate-overlay {
+ width: auto;
+ height: auto;
+ }
+
&.cld-fluid {
width: 100%;
max-width: 100%;
diff --git a/src/config/configSchema.json b/src/config/configSchema.json
index c91da5e2c..507ab69e1 100644
--- a/src/config/configSchema.json
+++ b/src/config/configSchema.json
@@ -578,6 +578,22 @@
},
"default": {}
},
+ "lazy": {
+ "oneOf": [
+ {
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "loadOnScroll": { "type": "boolean", "default": false }
+ },
+ "additionalProperties": false
+ }
+ ],
+ "default": false
+ },
"videoSources": {
"type": "array",
"items": {
diff --git a/src/utils/bootstrap-poster-url.js b/src/utils/bootstrap-poster-url.js
new file mode 100644
index 000000000..e6e18d138
--- /dev/null
+++ b/src/utils/bootstrap-poster-url.js
@@ -0,0 +1,13 @@
+import { buildPosterUrl } from './poster-url';
+
+export const getPosterUrl = (options) => {
+ if (typeof options?.poster === 'string' && options.poster.length > 0) {
+ return options.poster;
+ }
+ const cloudName = options?.cloudName || options?.cloud_name || options?.cloudinaryConfig?.cloud_name;
+ const publicId = options?.publicId || options?.sourceOptions?.publicId;
+ if (cloudName && publicId) {
+ return buildPosterUrl(cloudName, publicId, options?.cloudinaryConfig || { cloud_name: cloudName });
+ }
+ throw new Error('lazy requires a poster URL or cloudName and publicId');
+};
diff --git a/src/utils/get-analytics-player-options.js b/src/utils/get-analytics-player-options.js
index d180ebabd..7376df059 100644
--- a/src/utils/get-analytics-player-options.js
+++ b/src/utils/get-analytics-player-options.js
@@ -130,6 +130,10 @@ export const getAnalyticsFromPlayerOptions = (playerOptions) => filterDefaultsAn
debug: playerOptions.debug,
type: playerOptions.type,
schedule: hasConfig(playerOptions.schedule?.weekly),
+ lazy: playerOptions.lazy === true || (playerOptions.lazy && typeof playerOptions.lazy === 'object'),
+ lazyLoadOnScroll: (playerOptions.lazy && typeof playerOptions.lazy === 'object' && playerOptions.lazy.loadOnScroll === true)
+ ? true
+ : undefined,
colors: hasConfig(playerOptions.colors),
controlBar: hasConfig(playerOptions.controlBar),
diff --git a/src/utils/lazy-player.js b/src/utils/lazy-player.js
new file mode 100644
index 000000000..4228b2600
--- /dev/null
+++ b/src/utils/lazy-player.js
@@ -0,0 +1,180 @@
+import cssEscape from 'css.escape';
+import { getPosterUrl } from './bootstrap-poster-url';
+
+const FLUID_CLASS = 'cld-fluid';
+
+export const getVideoElement = (elem) => {
+ if (typeof elem === 'string') {
+ let id = elem;
+ if (id.indexOf('#') === 0) id = id.slice(1);
+ try {
+ elem = document.querySelector(`#${cssEscape(id)}`);
+ } catch {
+ elem = null;
+ }
+ if (!elem) throw new Error(`Could not find element with id ${id}`);
+ }
+ if (!elem?.tagName) throw new Error('Must specify either an element or an element id.');
+ if (elem.tagName !== 'VIDEO') throw new Error('Element is not a video tag.');
+ return elem;
+};
+
+export const preparePlayerPlaceholder = (videoElement, posterUrl, options = {}) => {
+ const hadControls = videoElement.hasAttribute('controls');
+
+ videoElement.poster = posterUrl;
+ videoElement.preload = 'none';
+ videoElement.controls = false;
+ videoElement.removeAttribute('controls');
+
+ const fluid = options.fluid !== false;
+ if (fluid) {
+ videoElement.classList.add(FLUID_CLASS);
+ }
+
+ if (options.width) videoElement.setAttribute('width', String(options.width));
+ if (options.height) videoElement.setAttribute('height', String(options.height));
+
+ const ar = options?.sourceOptions?.aspectRatio || options?.aspectRatio;
+ if (typeof ar === 'string' && ar.includes(':')) {
+ const parts = ar.split(':').map((x) => parseInt(x.trim(), 10));
+ if (parts.length === 2 && parts[0] > 0 && parts[1] > 0) {
+ videoElement.style.aspectRatio = `${parts[0]} / ${parts[1]}`;
+ }
+ }
+
+ return { videoElement, hadControls };
+};
+
+export const loadPlayer = ({ overlayRoot, videoElement, options, ready }) => {
+ if (overlayRoot?.parentNode) {
+ overlayRoot.replaceWith(videoElement);
+ }
+ return import('../video-player.js').then((m) => m.createVideoPlayer(videoElement, options, ready));
+};
+
+const PREACTIVATE_OVERLAY_CLASS = 'cld-lazy-preactivate-overlay';
+
+const isLightSkin = (videoElement, options) => {
+ const cls = videoElement.className || '';
+ return cls.indexOf('cld-video-player-skin-light') > -1 || options?.skin === 'light';
+};
+
+/** Matches Video.js BigPlayButton DOM structure. */
+const createBigPlayButton = () => {
+ const playBtn = document.createElement('button');
+ playBtn.type = 'button';
+ playBtn.className = 'vjs-big-play-button';
+ playBtn.setAttribute('aria-disabled', 'false');
+ playBtn.title = 'Play Video';
+ playBtn.setAttribute('aria-label', 'Play Video');
+ const icon = document.createElement('span');
+ icon.className = 'vjs-icon-placeholder';
+ icon.setAttribute('aria-hidden', 'true');
+ playBtn.appendChild(icon);
+ return playBtn;
+};
+
+export const shouldUseLazyBootstrap = (options) => !!options?.lazy;
+
+export const shouldLoadOnScroll = (lazy) =>
+ lazy && typeof lazy === 'object' && lazy.loadOnScroll === true;
+
+export const lazyBootstrap = (elem, options, ready) => {
+ const videoElement = getVideoElement(elem);
+ const posterUrl = getPosterUrl(options);
+ const loadOnScroll = shouldLoadOnScroll(options.lazy);
+
+ const { hadControls } = preparePlayerPlaceholder(videoElement, posterUrl, {
+ fluid: options?.fluid !== false,
+ width: options?.width,
+ height: options?.height,
+ sourceOptions: options?.sourceOptions,
+ aspectRatio: options?.aspectRatio
+ });
+
+ const light = isLightSkin(videoElement, options);
+
+ const overlayRoot = document.createElement('div');
+ overlayRoot.classList.add('cld-video-player', 'video-js', PREACTIVATE_OVERLAY_CLASS);
+ overlayRoot.classList.add(light ? 'cld-video-player-skin-light' : 'cld-video-player-skin-dark');
+
+ const colors = options?.colors;
+ if (colors) {
+ if (colors.base) overlayRoot.style.setProperty('--color-base', colors.base);
+ if (colors.accent) overlayRoot.style.setProperty('--color-accent', colors.accent);
+ if (colors.text) overlayRoot.style.setProperty('--color-text', colors.text);
+ }
+
+ videoElement.parentNode.insertBefore(overlayRoot, videoElement);
+ overlayRoot.appendChild(videoElement);
+
+ const playBtn = createBigPlayButton();
+ overlayRoot.appendChild(playBtn);
+
+ let loadPromise = null;
+ let observer = null;
+
+ const teardownActivation = () => {
+ playBtn.removeEventListener('click', onPlayClick);
+ videoElement.removeEventListener('click', onVideoClick);
+ if (observer) {
+ observer.disconnect();
+ observer = null;
+ }
+ };
+
+ const activate = (activationOpts = {}) => {
+ if (loadPromise) {
+ return loadPromise;
+ }
+ teardownActivation();
+ const autoplayFromUserGesture = activationOpts.autoplayFromUserGesture === true;
+ if (hadControls) videoElement.setAttribute('controls', '');
+ const wrappedReady = autoplayFromUserGesture
+ ? (p) => { p.play(); if (ready) ready(p); }
+ : ready;
+ const playerOptions = Object.assign({}, options);
+ delete playerOptions.lazy;
+ loadPromise = loadPlayer({
+ overlayRoot,
+ videoElement,
+ options: playerOptions,
+ ready: wrappedReady
+ });
+ return loadPromise;
+ };
+
+ function onPlayClick(e) {
+ e.stopPropagation();
+ activate({ autoplayFromUserGesture: true });
+ }
+
+ function onVideoClick() {
+ activate({ autoplayFromUserGesture: true });
+ }
+
+ playBtn.addEventListener('click', onPlayClick);
+ videoElement.addEventListener('click', onVideoClick);
+
+ if (loadOnScroll && typeof IntersectionObserver !== 'undefined') {
+ observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ activate({});
+ }
+ });
+ },
+ { rootMargin: '0px', threshold: 0.25 }
+ );
+ observer.observe(videoElement);
+ }
+
+ const stub = {
+ source: () => stub,
+ loadPlayer: () => activate({ autoplayFromUserGesture: true })
+ };
+
+ return stub;
+};
diff --git a/src/utils/player-api.js b/src/utils/player-api.js
index 6113aa991..446acfb95 100644
--- a/src/utils/player-api.js
+++ b/src/utils/player-api.js
@@ -1,8 +1,10 @@
-import { scheduleBootstrap, shouldUseScheduleBootstrap, getElementForSchedule } from './schedule';
+import { scheduleBootstrap, shouldUseScheduleBootstrap } from './schedule';
+import { lazyBootstrap, shouldUseLazyBootstrap } from './lazy-player';
+import { getVideoElement } from './lazy-player';
export const createAsyncPlayer = async (id, playerOptions, ready, createFn) => {
const mergedOptions = Object.assign({}, playerOptions);
- const videoElement = getElementForSchedule(id);
+ const videoElement = getVideoElement(id);
const opts = await (async () => {
try {
@@ -15,7 +17,11 @@ export const createAsyncPlayer = async (id, playerOptions, ready, createFn) => {
})();
if (shouldUseScheduleBootstrap(opts)) {
- return scheduleBootstrap(id, opts);
+ return scheduleBootstrap(id, opts, ready);
+ }
+
+ if (shouldUseLazyBootstrap(opts)) {
+ return lazyBootstrap(id, opts, ready);
}
return createFn(videoElement, opts, ready);
diff --git a/src/utils/schedule.js b/src/utils/schedule.js
index 69184ee4d..1c8129af9 100644
--- a/src/utils/schedule.js
+++ b/src/utils/schedule.js
@@ -1,9 +1,11 @@
/**
- * Schedule utilities: weekly time-range parsing and bootstrap (poster rendering).
+ * Schedule utilities: weekly time-range parsing and bootstrap.
+ * Outside-schedule bootstrap reuses lazy placeholder DOM + deferred load helpers.
* Uses browser local time. No videojs dependency for the bootstrap path.
*/
-import cssEscape from 'css.escape';
import { buildPosterUrl } from './poster-url';
+import { loadPlayer } from './lazy-player';
+import { getVideoElement, preparePlayerPlaceholder } from './lazy-player';
const INTERNAL_ANALYTICS_URL = 'https://analytics-api-s.cloudinary.com';
@@ -42,10 +44,11 @@ export const shouldUseScheduleBootstrap = (options) => {
* Bootstrap path when outside schedule: render poster, return stub with loadPlayer().
* @param {string|HTMLElement} elem - Element id or video element
* @param {object} options - player options
+ * @param {function} [ready] - Video.js ready callback (passed when full player loads)
* @returns {object} Stub with source() and loadPlayer()
*/
-export const scheduleBootstrap = (elem, options) => {
- const videoElement = getElementForSchedule(elem);
+export const scheduleBootstrap = (elem, options, ready) => {
+ const videoElement = getVideoElement(elem);
const cloudName = getCloudNameFromOptions(options);
const publicId = getPublicIdFromOptions(options);
@@ -57,11 +60,13 @@ export const scheduleBootstrap = (elem, options) => {
const posterUrl = buildPosterUrl(cloudName, publicId, cloudinaryConfig);
const fluid = options?.fluid !== false;
- const { container, videoElement: vEl } = renderScheduleImage(videoElement, posterUrl, {
+ const { videoElement: vEl, hadControls } = preparePlayerPlaceholder(videoElement, posterUrl, {
fluid,
width: options?.width,
height: options?.height,
cropMode: options?.sourceOptions?.cropMode,
+ sourceOptions: options?.sourceOptions,
+ aspectRatio: options?.aspectRatio
});
sendScheduleImageAnalytics(options);
@@ -69,11 +74,8 @@ export const scheduleBootstrap = (elem, options) => {
const stub = {
source: () => stub,
loadPlayer: () => {
- if (container && container.parentNode) {
- container.parentNode.removeChild(container);
- }
- vEl.style.display = '';
- return import('../video-player.js').then((m) => m.createVideoPlayer(vEl, options));
+ if (hadControls) vEl.setAttribute('controls', '');
+ return loadPlayer({ videoElement: vEl, options, ready });
}
};
@@ -90,8 +92,6 @@ const DAY_MAP = {
saturday: 6, sat: 6
};
-const FLUID_CLASS = 'cld-fluid';
-
/**
* Parse readable day-of-week string to JS Date.getDay() value (0=Sun .. 6=Sat).
* @param {string} day - Full or abbreviated day name (case-insensitive)
@@ -148,58 +148,4 @@ export const isWithinSchedule = (schedule, date) => {
return false;
};
-/**
- * Resolve video element by id or return element. No videojs.
- * @param {string|HTMLElement} elem - Element id (with or without #) or video element
- * @returns {HTMLVideoElement}
- */
-export const getElementForSchedule = (elem) => {
- if (typeof elem === 'string') {
- let id = elem;
- if (id.indexOf('#') === 0) id = id.slice(1);
- try {
- elem = document.querySelector(`#${cssEscape(id)}`);
- } catch {
- elem = null;
- }
- if (!elem) throw new Error(`Could not find element with id ${id}`);
- }
- if (!elem?.tagName) throw new Error('Must specify either an element or an element id.');
- if (elem.tagName !== 'VIDEO') throw new Error('Element is not a video tag.');
- return elem;
-};
-
-/**
- * Hide video, show poster image overlay. Keeps video in DOM for load().
- * @param {HTMLVideoElement} videoElement
- * @param {string} posterUrl
- * @param {object} options - fluid, width, height, etc.
- * @returns {{ img: HTMLImageElement, container: HTMLElement, videoElement: HTMLVideoElement }}
- */
-export const renderScheduleImage = (videoElement, posterUrl, options = {}) => {
- const fluid = options.fluid !== false;
- const parent = videoElement.parentNode;
-
- const container = document.createElement('div');
- container.className = 'cld-schedule-poster-container';
- container.style.cssText = 'position:relative;width:100%;height:100%;';
-
- const img = document.createElement('img');
- img.src = posterUrl;
- img.alt = '';
- img.setAttribute('data-cld-schedule-poster', 'true');
- img.style.cssText = 'display:block;width:100%;height:100%;object-fit:contain;';
-
- if (fluid) {
- container.classList.add(FLUID_CLASS);
- img.style.objectFit = options.cropMode === 'fill' ? 'cover' : 'contain';
- }
- if (options.width) container.style.width = `${options.width}px`;
- if (options.height) container.style.height = `${options.height}px`;
-
- videoElement.style.display = 'none';
- container.appendChild(img);
- parent.insertBefore(container, videoElement);
-
- return { img, container, videoElement };
-};
+export { getVideoElement as getElementForSchedule, preparePlayerPlaceholder as renderScheduleImage } from './lazy-player';
diff --git a/src/validators/validators.js b/src/validators/validators.js
index 2ed10b946..aadf01dd5 100644
--- a/src/validators/validators.js
+++ b/src/validators/validators.js
@@ -77,6 +77,7 @@ export const playerValidators = {
duration: validator.isNumber
})
},
+ lazy: validator.or(validator.isBoolean, validator.isPlainObject),
cloudinary: {
autoShowRecommendations: validator.isBoolean,
sourceTypes: validator.isArrayOfStrings,
diff --git a/src/video-player.const.js b/src/video-player.const.js
index f5b7b9a92..609af8196 100644
--- a/src/video-player.const.js
+++ b/src/video-player.const.js
@@ -62,6 +62,7 @@ export const PLAYER_PARAMS = SOURCE_PARAMS.concat([
'showJumpControls',
'videoConfig',
'schedule',
+ 'lazy',
]);
// We support both camelCase and snake_case for cloudinary SDK params
diff --git a/test/unit/bootstrap-poster-url.test.js b/test/unit/bootstrap-poster-url.test.js
new file mode 100644
index 000000000..61d17a47b
--- /dev/null
+++ b/test/unit/bootstrap-poster-url.test.js
@@ -0,0 +1,25 @@
+import { getPosterUrl } from '../../src/utils/bootstrap-poster-url';
+
+describe('bootstrap-poster-url', () => {
+ it('returns poster string when set', () => {
+ expect(getPosterUrl({ poster: 'https://example.com/p.jpg' })).toBe(
+ 'https://example.com/p.jpg'
+ );
+ });
+
+ it('builds URL from cloudName and publicId', () => {
+ const url = getPosterUrl({
+ cloudName: 'demo',
+ publicId: 'dog'
+ });
+ expect(url).toContain('demo');
+ expect(url).toContain('dog');
+ expect(url).toContain('video/upload');
+ });
+
+ it('throws when no poster or ids', () => {
+ expect(() => getPosterUrl({ cloudName: 'demo' })).toThrow(
+ 'lazy requires a poster URL or cloudName and publicId'
+ );
+ });
+});
diff --git a/test/unit/lazy-bootstrap.test.js b/test/unit/lazy-bootstrap.test.js
new file mode 100644
index 000000000..6feb3384c
--- /dev/null
+++ b/test/unit/lazy-bootstrap.test.js
@@ -0,0 +1,85 @@
+import { shouldUseLazyBootstrap, shouldLoadOnScroll, lazyBootstrap } from '../../src/utils/lazy-player';
+
+describe('lazy-bootstrap', () => {
+ describe('shouldUseLazyBootstrap', () => {
+ it('is false when lazy missing or falsy', () => {
+ expect(shouldUseLazyBootstrap({})).toBe(false);
+ expect(shouldUseLazyBootstrap({ lazy: false })).toBe(false);
+ });
+
+ it('is true when lazy is true or object', () => {
+ expect(shouldUseLazyBootstrap({ lazy: true })).toBe(true);
+ expect(shouldUseLazyBootstrap({ lazy: {} })).toBe(true);
+ expect(shouldUseLazyBootstrap({ lazy: { loadOnScroll: true } })).toBe(true);
+ });
+ });
+
+ describe('shouldLoadOnScroll', () => {
+ it('false for boolean true', () => {
+ expect(shouldLoadOnScroll(true)).toBe(false);
+ });
+
+ it('true when loadOnScroll is true', () => {
+ expect(shouldLoadOnScroll({ loadOnScroll: true })).toBe(true);
+ });
+
+ it('false for empty object', () => {
+ expect(shouldLoadOnScroll({})).toBe(false);
+ });
+ });
+
+ describe('lazyBootstrap DOM', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('wraps video in overlay with big-play and sets poster', () => {
+ const stub = lazyBootstrap('lazy-vid', {
+ lazy: true,
+ cloudName: 'demo',
+ publicId: 'dog'
+ });
+
+ const video = document.getElementById('lazy-vid');
+ expect(video.poster).toContain('dog');
+
+ const overlay = document.querySelector('.cld-lazy-preactivate-overlay');
+ expect(overlay).toBeTruthy();
+ expect(overlay.classList.contains('cld-video-player')).toBe(true);
+ expect(overlay.classList.contains('cld-video-player-skin-dark')).toBe(true);
+ expect(overlay.contains(video)).toBe(true);
+ const btn = overlay.querySelector('.vjs-big-play-button');
+ expect(btn).toBeTruthy();
+ expect(btn.querySelector('.vjs-icon-placeholder')).toBeTruthy();
+ expect(stub.source()).toBe(stub);
+ expect(typeof stub.loadPlayer).toBe('function');
+ });
+
+ it('uses light skin when video has skin-light class', () => {
+ document.body.innerHTML = '';
+ lazyBootstrap('lazy-vid', { lazy: true, cloudName: 'demo', publicId: 'dog' });
+ const video = document.getElementById('lazy-vid');
+ const overlay = document.querySelector('.cld-lazy-preactivate-overlay');
+ expect(video.classList.contains('cld-video-player-skin-light')).toBe(true);
+ expect(overlay.classList.contains('cld-video-player-skin-light')).toBe(true);
+ expect(video.classList.contains('cld-video-player-skin-dark')).toBe(false);
+ });
+
+ it('applies colors option as CSS custom properties on overlay wrapper', () => {
+ lazyBootstrap('lazy-vid', {
+ lazy: true,
+ cloudName: 'demo',
+ publicId: 'dog',
+ colors: { base: '#112233', accent: '#aabbcc', text: '#ffeedd' }
+ });
+ const overlay = document.querySelector('.cld-lazy-preactivate-overlay');
+ expect(overlay.style.getPropertyValue('--color-base').trim()).toBe('#112233');
+ expect(overlay.style.getPropertyValue('--color-accent').trim()).toBe('#aabbcc');
+ expect(overlay.style.getPropertyValue('--color-text').trim()).toBe('#ffeedd');
+ });
+ });
+});
diff --git a/test/unit/lazy-placeholder.test.js b/test/unit/lazy-placeholder.test.js
new file mode 100644
index 000000000..8944b2479
--- /dev/null
+++ b/test/unit/lazy-placeholder.test.js
@@ -0,0 +1,68 @@
+import { getVideoElement, preparePlayerPlaceholder } from '../../src/utils/lazy-player';
+
+describe('lazy-placeholder', () => {
+ describe('getVideoElement', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('resolves element by id', () => {
+ const el = getVideoElement('test-video');
+ expect(el.tagName).toBe('VIDEO');
+ expect(el.id).toBe('test-video');
+ });
+
+ it('strips # prefix from id', () => {
+ const el = getVideoElement('#test-video');
+ expect(el.id).toBe('test-video');
+ });
+
+ it('returns element when passed directly', () => {
+ const video = document.getElementById('test-video');
+ const el = getVideoElement(video);
+ expect(el).toBe(video);
+ });
+
+ it('throws when element not found', () => {
+ expect(() => getVideoElement('nonexistent')).toThrow('Could not find element');
+ });
+
+ it('throws when element is not a video tag', () => {
+ document.body.innerHTML = '';
+ expect(() => getVideoElement('not-video')).toThrow('Element is not a video tag');
+ });
+ });
+
+ describe('preparePlayerPlaceholder', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('sets native poster and turns off controls', () => {
+ const video = document.getElementById('vid');
+ video.src = 'https://example.com/x.mp4';
+ video.controls = true;
+ const result = preparePlayerPlaceholder(video, 'https://example.com/poster.jpg', {});
+
+ expect(result.videoElement).toBe(video);
+ expect(video.poster).toBe('https://example.com/poster.jpg');
+ expect(video.preload).toBe('none');
+ expect(video.src).toContain('https://example.com/x.mp4');
+ expect(video.controls).toBe(false);
+ });
+
+ it('adds cld-fluid when fluid is not false', () => {
+ const video = document.getElementById('vid');
+ preparePlayerPlaceholder(video, 'https://example.com/p.jpg', { fluid: true });
+ expect(video.classList.contains('cld-fluid')).toBe(true);
+ });
+ });
+});
diff --git a/test/unit/schedule-bootstrap.test.js b/test/unit/schedule-bootstrap.test.js
deleted file mode 100644
index cbb77da8b..000000000
--- a/test/unit/schedule-bootstrap.test.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import { getElementForSchedule, renderScheduleImage } from '../../src/utils/schedule';
-
-describe('schedule-bootstrap', () => {
- describe('getElementForSchedule', () => {
- beforeEach(() => {
- document.body.innerHTML = '';
- });
-
- afterEach(() => {
- document.body.innerHTML = '';
- });
-
- it('resolves element by id', () => {
- const el = getElementForSchedule('test-video');
- expect(el.tagName).toBe('VIDEO');
- expect(el.id).toBe('test-video');
- });
-
- it('strips # prefix from id', () => {
- const el = getElementForSchedule('#test-video');
- expect(el.id).toBe('test-video');
- });
-
- it('returns element when passed directly', () => {
- const video = document.getElementById('test-video');
- const el = getElementForSchedule(video);
- expect(el).toBe(video);
- });
-
- it('throws when element not found', () => {
- expect(() => getElementForSchedule('nonexistent')).toThrow('Could not find element');
- });
-
- it('throws when element is not a video tag', () => {
- document.body.innerHTML = '';
- expect(() => getElementForSchedule('not-video')).toThrow('Element is not a video tag');
- });
- });
-
- describe('renderScheduleImage', () => {
- beforeEach(() => {
- document.body.innerHTML = '';
- });
-
- afterEach(() => {
- document.body.innerHTML = '';
- });
-
- it('hides video and inserts poster image', () => {
- const video = document.getElementById('vid');
- const result = renderScheduleImage(video, 'https://example.com/poster.jpg', {});
-
- expect(video.style.display).toBe('none');
- expect(result.img.src).toBe('https://example.com/poster.jpg');
- expect(result.container).toBeTruthy();
- expect(result.videoElement).toBe(video);
- });
-
- it('returns refs for load() to use', () => {
- const video = document.getElementById('vid');
- const result = renderScheduleImage(video, 'https://example.com/poster.jpg', {});
-
- expect(result.img).toBeInstanceOf(HTMLImageElement);
- expect(result.container).toBeInstanceOf(HTMLElement);
- expect(result.videoElement).toBe(video);
- });
- });
-});
diff --git a/types/cld-video-player.d.ts b/types/cld-video-player.d.ts
index ca889b223..68b2e87a6 100644
--- a/types/cld-video-player.d.ts
+++ b/types/cld-video-player.d.ts
@@ -1,16 +1,22 @@
type VideoPlayerFunction = (id: string, options?: any, ready?: () => void) => VideoPlayer;
type VideoMultiPlayersFunction = (selector: string, options?: any, ready?: () => void) => VideoPlayer[];
type VideoPlayerWithProfileFunction = (id: string, options?: any, ready?: () => void) => Promise;
+type AsyncPlayerFunction = (id: string, options?: any, ready?: () => void) => Promise unknown; loadPlayer: () => Promise }>;
+type AsyncPlayersFunction = (selector: string, options?: any, ready?: () => void) => Promise;
export const videoPlayer: VideoPlayerFunction;
export const videoPlayers: VideoMultiPlayersFunction;
export const videoPlayerWithProfile: VideoPlayerWithProfileFunction;
+export const player: AsyncPlayerFunction;
+export const players: AsyncPlayersFunction;
export interface Cloudinary {
videoPlayer: VideoPlayerFunction;
videoPlayers: VideoMultiPlayersFunction;
videoPlayerWithProfile: VideoPlayerWithProfileFunction;
+ player: AsyncPlayerFunction;
+ players: AsyncPlayersFunction;
}
declare global {
From 9c2431afdb06eb8f758c4cb1136a22c4f5bceedc Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Tue, 31 Mar 2026 12:15:10 +0300
Subject: [PATCH 02/10] chore: examples
---
docs/es-modules/lazy-player.html | 36 +++++++++++++++++---------------
docs/index.html | 2 +-
2 files changed, 20 insertions(+), 18 deletions(-)
diff --git a/docs/es-modules/lazy-player.html b/docs/es-modules/lazy-player.html
index 5b2dc67c0..c624214ae 100644
--- a/docs/es-modules/lazy-player.html
+++ b/docs/es-modules/lazy-player.html
@@ -83,25 +83,27 @@ Example code:
const poster = 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg';
- const player = await createPlayer('player', {
- cloudName: 'demo',
- publicId: 'sea_turtle',
- poster,
- lazy: true
- });
+ (async () => {
+ const player = await createPlayer('player', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ poster,
+ lazy: true
+ });
- createPlayer('player-scroll', {
- cloudName: 'demo',
- publicId: 'sea_turtle',
- poster,
- lazy: { loadOnScroll: true }
- });
+ document.getElementById('btn-load').addEventListener('click', () => {
+ if (player && typeof player.loadPlayer === 'function') {
+ player.loadPlayer();
+ }
+ });
- document.getElementById('btn-load').addEventListener('click', () => {
- if (player && typeof player.loadPlayer === 'function') {
- player.loadPlayer();
- }
- });
+ createPlayer('player-scroll', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ poster,
+ lazy: { loadOnScroll: true }
+ });
+ })();
Some code examples:
Force HLS Subtitles
HDR
Interaction Area
- Lazy player (poster until click)
+ Lazy player
Live Streaming
Multiple Players
Playlist
From 86148e2d49cbad9d80a31a4c76eafa6cac96207d Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Tue, 31 Mar 2026 13:52:13 +0300
Subject: [PATCH 03/10] chore: improve bundle size
---
.config/vitest.config.ts | 1 +
docs/es-modules/lazy-player.html | 35 ++++++++++++++------------
docs/lazy-player.html | 35 ++++++++++++++------------
src/utils/bootstrap-poster-url.js | 5 ++--
src/utils/lazy-player.js | 7 +++---
src/utils/player-api.js | 8 +++---
src/utils/schedule.js | 4 +--
src/video-player.utils.js | 3 +--
test/unit/bootstrap-poster-url.test.js | 12 ++++-----
test/unit/lazy-bootstrap.test.js | 12 ++++-----
test/unit/setup.js | 9 +++++++
11 files changed, 73 insertions(+), 58 deletions(-)
create mode 100644 test/unit/setup.js
diff --git a/.config/vitest.config.ts b/.config/vitest.config.ts
index 37abf8271..1ac7e1d8c 100644
--- a/.config/vitest.config.ts
+++ b/.config/vitest.config.ts
@@ -5,6 +5,7 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
+ setupFiles: ['test/unit/setup.js'],
include: ['test/unit/**/*.test.js'],
exclude: [
'test/*.test.js',
diff --git a/docs/es-modules/lazy-player.html b/docs/es-modules/lazy-player.html
index c624214ae..e7dc0966f 100644
--- a/docs/es-modules/lazy-player.html
+++ b/docs/es-modules/lazy-player.html
@@ -17,7 +17,8 @@
<< Back to examples index
Cloudinary Video Player
- Lazy player
+ Lazy player (with poster URL)
+ Uses an explicit poster URL. Custom accent color and logo configured.
- Scroll to load
+ Scroll to load (auto-built poster)
+ No poster URL - built automatically from cloudName and publicId. Custom colors configured.
Scroll down - the player loads automatically when it enters the viewport.
@@ -47,6 +49,7 @@ Scroll to load
playsinline
controls
muted
+ loop
class="cld-video-player cld-fluid"
width="500"
>
@@ -56,23 +59,22 @@ Example code:
import { player as createPlayer } from 'cloudinary-video-player/lazy';
import 'cloudinary-video-player/cld-video-player.min.css';
-const poster = 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg';
-
-// Click to load
+// With explicit poster URL
const player = await createPlayer('my-video', {
cloudName: 'demo',
publicId: 'sea_turtle',
- poster,
- lazy: true
+ poster: 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg',
+ lazy: true,
+ colors: { accent: '#ff4081' }
});
// await player.loadPlayer();
-// Scroll to load
+// Auto-built poster from cloudName + publicId
createPlayer('my-video', {
cloudName: 'demo',
publicId: 'sea_turtle',
- poster,
- lazy: { loadOnScroll: true }
+ lazy: { loadOnScroll: true },
+ colors: { base: '#0d1b2a', accent: '#00b4d8' }
});
@@ -81,14 +83,15 @@ Example code:
import { player as createPlayer } from 'cloudinary-video-player/lazy';
import 'cloudinary-video-player/cld-video-player.min.css';
- const poster = 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg';
-
(async () => {
const player = await createPlayer('player', {
cloudName: 'demo',
publicId: 'sea_turtle',
- poster,
- lazy: true
+ poster: 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg',
+ lazy: true,
+ colors: { accent: '#ff4081' },
+ logoImageUrl: 'https://res.cloudinary.com/demo/image/upload/c_scale,w_100/cloudinary_logo.png',
+ logoOnclickUrl: 'https://cloudinary.com'
});
document.getElementById('btn-load').addEventListener('click', () => {
@@ -100,8 +103,8 @@ Example code:
createPlayer('player-scroll', {
cloudName: 'demo',
publicId: 'sea_turtle',
- poster,
- lazy: { loadOnScroll: true }
+ lazy: { loadOnScroll: true },
+ colors: { base: '#0d1b2a', accent: '#00b4d8' }
});
})();
diff --git a/docs/lazy-player.html b/docs/lazy-player.html
index 934e3018a..2378061a7 100644
--- a/docs/lazy-player.html
+++ b/docs/lazy-player.html
@@ -21,20 +21,21 @@
var player = null;
async function initLazyPlayer() {
- var poster = 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg';
-
player = await cloudinary.player('player', {
cloudName: 'demo',
publicId: 'sea_turtle',
- poster: poster,
- lazy: true
+ poster: 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg',
+ lazy: true,
+ colors: { accent: '#ff4081' },
+ logoImageUrl: 'https://res.cloudinary.com/demo/image/upload/c_scale,w_100/cloudinary_logo.png',
+ logoOnclickUrl: 'https://cloudinary.com'
});
cloudinary.player('player-scroll', {
cloudName: 'demo',
publicId: 'sea_turtle',
- poster: poster,
- lazy: { loadOnScroll: true }
+ lazy: { loadOnScroll: true },
+ colors: { accent: '#ff4081' }
});
}
@@ -55,7 +56,8 @@
<< Back to examples index
Cloudinary Video Player
- Lazy player
+ Lazy player (with poster URL)
+ Uses an explicit poster URL. Custom accent color and logo configured.
@@ -92,23 +96,22 @@ Scroll to load
Example code:
- const poster = 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg';
-
- // Click to load
+ // With explicit poster URL
const player = await cloudinary.player('my-video', {
cloudName: 'demo',
publicId: 'sea_turtle',
- poster: poster,
- lazy: true
+ poster: 'https://res.cloudinary.com/demo/video/upload/so_0,f_auto,q_auto/sea_turtle.jpg',
+ lazy: true,
+ colors: { accent: '#ff4081' }
});
// await player.loadPlayer();
- // Scroll to load
+ // Auto-built poster from cloudName + publicId
cloudinary.player('my-video', {
cloudName: 'demo',
publicId: 'sea_turtle',
- poster: poster,
- lazy: { loadOnScroll: true }
+ lazy: { loadOnScroll: true },
+ colors: { base: '#0d1b2a', accent: '#00b4d8' }
});
diff --git a/src/utils/bootstrap-poster-url.js b/src/utils/bootstrap-poster-url.js
index e6e18d138..f362b642e 100644
--- a/src/utils/bootstrap-poster-url.js
+++ b/src/utils/bootstrap-poster-url.js
@@ -1,12 +1,11 @@
-import { buildPosterUrl } from './poster-url';
-
-export const getPosterUrl = (options) => {
+export const getPosterUrl = async (options) => {
if (typeof options?.poster === 'string' && options.poster.length > 0) {
return options.poster;
}
const cloudName = options?.cloudName || options?.cloud_name || options?.cloudinaryConfig?.cloud_name;
const publicId = options?.publicId || options?.sourceOptions?.publicId;
if (cloudName && publicId) {
+ const { buildPosterUrl } = await import('./poster-url');
return buildPosterUrl(cloudName, publicId, options?.cloudinaryConfig || { cloud_name: cloudName });
}
throw new Error('lazy requires a poster URL or cloudName and publicId');
diff --git a/src/utils/lazy-player.js b/src/utils/lazy-player.js
index 4228b2600..2fad68260 100644
--- a/src/utils/lazy-player.js
+++ b/src/utils/lazy-player.js
@@ -1,4 +1,3 @@
-import cssEscape from 'css.escape';
import { getPosterUrl } from './bootstrap-poster-url';
const FLUID_CLASS = 'cld-fluid';
@@ -8,7 +7,7 @@ export const getVideoElement = (elem) => {
let id = elem;
if (id.indexOf('#') === 0) id = id.slice(1);
try {
- elem = document.querySelector(`#${cssEscape(id)}`);
+ elem = document.querySelector(`#${CSS.escape(id)}`);
} catch {
elem = null;
}
@@ -80,9 +79,9 @@ export const shouldUseLazyBootstrap = (options) => !!options?.lazy;
export const shouldLoadOnScroll = (lazy) =>
lazy && typeof lazy === 'object' && lazy.loadOnScroll === true;
-export const lazyBootstrap = (elem, options, ready) => {
+export const lazyBootstrap = async (elem, options, ready) => {
const videoElement = getVideoElement(elem);
- const posterUrl = getPosterUrl(options);
+ const posterUrl = await getPosterUrl(options);
const loadOnScroll = shouldLoadOnScroll(options.lazy);
const { hadControls } = preparePlayerPlaceholder(videoElement, posterUrl, {
diff --git a/src/utils/player-api.js b/src/utils/player-api.js
index 446acfb95..c09d94713 100644
--- a/src/utils/player-api.js
+++ b/src/utils/player-api.js
@@ -1,4 +1,3 @@
-import { scheduleBootstrap, shouldUseScheduleBootstrap } from './schedule';
import { lazyBootstrap, shouldUseLazyBootstrap } from './lazy-player';
import { getVideoElement } from './lazy-player';
@@ -16,8 +15,11 @@ export const createAsyncPlayer = async (id, playerOptions, ready, createFn) => {
}
})();
- if (shouldUseScheduleBootstrap(opts)) {
- return scheduleBootstrap(id, opts, ready);
+ if (opts?.schedule?.weekly) {
+ const { shouldUseScheduleBootstrap, scheduleBootstrap } = await import('./schedule');
+ if (shouldUseScheduleBootstrap(opts)) {
+ return scheduleBootstrap(id, opts, ready);
+ }
}
if (shouldUseLazyBootstrap(opts)) {
diff --git a/src/utils/schedule.js b/src/utils/schedule.js
index 1c8129af9..0a1b9634a 100644
--- a/src/utils/schedule.js
+++ b/src/utils/schedule.js
@@ -3,7 +3,6 @@
* Outside-schedule bootstrap reuses lazy placeholder DOM + deferred load helpers.
* Uses browser local time. No videojs dependency for the bootstrap path.
*/
-import { buildPosterUrl } from './poster-url';
import { loadPlayer } from './lazy-player';
import { getVideoElement, preparePlayerPlaceholder } from './lazy-player';
@@ -47,7 +46,7 @@ export const shouldUseScheduleBootstrap = (options) => {
* @param {function} [ready] - Video.js ready callback (passed when full player loads)
* @returns {object} Stub with source() and loadPlayer()
*/
-export const scheduleBootstrap = (elem, options, ready) => {
+export const scheduleBootstrap = async (elem, options, ready) => {
const videoElement = getVideoElement(elem);
const cloudName = getCloudNameFromOptions(options);
const publicId = getPublicIdFromOptions(options);
@@ -56,6 +55,7 @@ export const scheduleBootstrap = (elem, options, ready) => {
throw new Error('schedule.weekly requires cloudName and publicId when outside schedule');
}
+ const { buildPosterUrl } = await import('./poster-url');
const cloudinaryConfig = options?.cloudinaryConfig || { cloud_name: cloudName };
const posterUrl = buildPosterUrl(cloudName, publicId, cloudinaryConfig);
diff --git a/src/video-player.utils.js b/src/video-player.utils.js
index 637a09301..45c97080d 100644
--- a/src/video-player.utils.js
+++ b/src/video-player.utils.js
@@ -16,7 +16,6 @@ import { convertKeysToSnakeCase } from './utils/object';
* characters such as digits.
* https://www.w3.org/International/questions/qa-escapes#css_identifiers
*/
-import cssEscape from 'css.escape';
export const addMetadataTrack = (videoJs, vttSource) => {
return videoJs.addRemoteTextTrack({
@@ -41,7 +40,7 @@ export const getResolveVideoElement = (elem) => {
}
try {
- elem = document.querySelector(`#${cssEscape(id)}`) || videojs.getPlayer(id);
+ elem = document.querySelector(`#${CSS.escape(id)}`) || videojs.getPlayer(id);
} catch (err) { // eslint-disable-line no-unused-vars
elem = null;
}
diff --git a/test/unit/bootstrap-poster-url.test.js b/test/unit/bootstrap-poster-url.test.js
index 61d17a47b..71109f414 100644
--- a/test/unit/bootstrap-poster-url.test.js
+++ b/test/unit/bootstrap-poster-url.test.js
@@ -1,14 +1,14 @@
import { getPosterUrl } from '../../src/utils/bootstrap-poster-url';
describe('bootstrap-poster-url', () => {
- it('returns poster string when set', () => {
- expect(getPosterUrl({ poster: 'https://example.com/p.jpg' })).toBe(
+ it('returns poster string when set', async () => {
+ expect(await getPosterUrl({ poster: 'https://example.com/p.jpg' })).toBe(
'https://example.com/p.jpg'
);
});
- it('builds URL from cloudName and publicId', () => {
- const url = getPosterUrl({
+ it('builds URL from cloudName and publicId', async () => {
+ const url = await getPosterUrl({
cloudName: 'demo',
publicId: 'dog'
});
@@ -17,8 +17,8 @@ describe('bootstrap-poster-url', () => {
expect(url).toContain('video/upload');
});
- it('throws when no poster or ids', () => {
- expect(() => getPosterUrl({ cloudName: 'demo' })).toThrow(
+ it('throws when no poster or ids', async () => {
+ await expect(getPosterUrl({ cloudName: 'demo' })).rejects.toThrow(
'lazy requires a poster URL or cloudName and publicId'
);
});
diff --git a/test/unit/lazy-bootstrap.test.js b/test/unit/lazy-bootstrap.test.js
index 6feb3384c..529eeaf6c 100644
--- a/test/unit/lazy-bootstrap.test.js
+++ b/test/unit/lazy-bootstrap.test.js
@@ -37,8 +37,8 @@ describe('lazy-bootstrap', () => {
document.body.innerHTML = '';
});
- it('wraps video in overlay with big-play and sets poster', () => {
- const stub = lazyBootstrap('lazy-vid', {
+ it('wraps video in overlay with big-play and sets poster', async () => {
+ const stub = await lazyBootstrap('lazy-vid', {
lazy: true,
cloudName: 'demo',
publicId: 'dog'
@@ -59,9 +59,9 @@ describe('lazy-bootstrap', () => {
expect(typeof stub.loadPlayer).toBe('function');
});
- it('uses light skin when video has skin-light class', () => {
+ it('uses light skin when video has skin-light class', async () => {
document.body.innerHTML = '';
- lazyBootstrap('lazy-vid', { lazy: true, cloudName: 'demo', publicId: 'dog' });
+ await lazyBootstrap('lazy-vid', { lazy: true, cloudName: 'demo', publicId: 'dog' });
const video = document.getElementById('lazy-vid');
const overlay = document.querySelector('.cld-lazy-preactivate-overlay');
expect(video.classList.contains('cld-video-player-skin-light')).toBe(true);
@@ -69,8 +69,8 @@ describe('lazy-bootstrap', () => {
expect(video.classList.contains('cld-video-player-skin-dark')).toBe(false);
});
- it('applies colors option as CSS custom properties on overlay wrapper', () => {
- lazyBootstrap('lazy-vid', {
+ it('applies colors option as CSS custom properties on overlay wrapper', async () => {
+ await lazyBootstrap('lazy-vid', {
lazy: true,
cloudName: 'demo',
publicId: 'dog',
diff --git a/test/unit/setup.js b/test/unit/setup.js
new file mode 100644
index 000000000..b96b9f448
--- /dev/null
+++ b/test/unit/setup.js
@@ -0,0 +1,9 @@
+if (typeof globalThis.CSS === 'undefined') {
+ globalThis.CSS = {};
+}
+if (typeof globalThis.CSS.escape !== 'function') {
+ globalThis.CSS.escape = (value) => {
+ const str = String(value);
+ return str.replace(/([^\w-])/g, '\\$1');
+ };
+}
From 46768c348daa35889c90e38c967a33ca51d25c62 Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Tue, 31 Mar 2026 13:58:33 +0300
Subject: [PATCH 04/10] chore: e2e
---
test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts | 2 +-
test/e2e/specs/NonESM/linksConsolErros.spec.ts | 2 +-
test/e2e/testData/ExampleLinkNames.ts | 4 ++--
test/e2e/testData/esmPageLinksData.ts | 2 +-
test/e2e/testData/pageLinksData.ts | 1 +
5 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
index 055b97170..1a7337b25 100644
--- a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
+++ b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
@@ -5,7 +5,7 @@ import { ExampleLinkName } from '../../testData/ExampleLinkNames';
import { getEsmLinkByName } from '../../testData/esmPageLinksData';
import { waitForPageToLoadWithTimeout } from '../../src/helpers/waitForPageToLoadWithTimeout';
-const link = getEsmLinkByName(ExampleLinkName.LazyLoading);
+const link = getEsmLinkByName(ExampleLinkName.LazyPlayer);
vpTest('Given lazy-loading page, when load button clicked, then player loads on demand', async ({ page, pomPages }) => {
await page.goto(ESM_URL);
diff --git a/test/e2e/specs/NonESM/linksConsolErros.spec.ts b/test/e2e/specs/NonESM/linksConsolErros.spec.ts
index 206fadd32..ffd660f55 100644
--- a/test/e2e/specs/NonESM/linksConsolErros.spec.ts
+++ b/test/e2e/specs/NonESM/linksConsolErros.spec.ts
@@ -24,7 +24,7 @@ for (const link of LINKS) {
* Testing number of links in page.
*/
vpTest('Link count test', async ({ page }) => {
- const expectedNumberOfLinks = 43;
+ const expectedNumberOfLinks = 44;
const numberOfLinks = await page.getByRole('link').count();
expect(numberOfLinks).toBe(expectedNumberOfLinks);
});
diff --git a/test/e2e/testData/ExampleLinkNames.ts b/test/e2e/testData/ExampleLinkNames.ts
index 29c8117ce..b4943366b 100644
--- a/test/e2e/testData/ExampleLinkNames.ts
+++ b/test/e2e/testData/ExampleLinkNames.ts
@@ -30,7 +30,7 @@ export enum ExampleLinkName {
Profiles = 'Profiles',
RawURL = 'Raw URL',
Recommendations = 'Recommendations',
- Schedule = 'Schedule (weekly time slots)',
+ Schedule = 'Schedule',
SeekThumbnails = 'Seek Thumbnails',
ShoppableVideos = 'Shoppable Videos',
SubtitlesAndCaptions = 'Subtitles & Captions',
@@ -45,5 +45,5 @@ export enum ExampleLinkName {
VisualSearch = 'Visual Search',
VideoDetails = 'Video Details',
EntryPoints = 'Entry Points (all exports)',
- LazyLoading = 'Lazy Loading',
+ LazyPlayer = 'Lazy player',
}
diff --git a/test/e2e/testData/esmPageLinksData.ts b/test/e2e/testData/esmPageLinksData.ts
index aebfbec3f..c2ae9e4d9 100644
--- a/test/e2e/testData/esmPageLinksData.ts
+++ b/test/e2e/testData/esmPageLinksData.ts
@@ -44,7 +44,7 @@ export const ESM_LINKS: ExampleLinkType[] = [
{ name: ExampleLinkName.AllBuild, endpoint: 'all' },
{ name: ExampleLinkName.VideoDetails, endpoint: 'video-details' },
{ name: ExampleLinkName.EntryPoints, endpoint: 'entry-points' },
- { name: ExampleLinkName.LazyLoading, endpoint: 'lazy-loading' },
+ { name: ExampleLinkName.LazyPlayer, endpoint: 'lazy-player' },
];
/**
diff --git a/test/e2e/testData/pageLinksData.ts b/test/e2e/testData/pageLinksData.ts
index 93ec5d9b3..35b57edc2 100644
--- a/test/e2e/testData/pageLinksData.ts
+++ b/test/e2e/testData/pageLinksData.ts
@@ -26,6 +26,7 @@ export const LINKS: ExampleLinkType[] = [
{ name: ExampleLinkName.ForceHLSSubtitles, endpoint: 'force-hls-subtitles-ios.html' },
{ name: ExampleLinkName.HDR, endpoint: 'hdr.html' },
{ name: ExampleLinkName.InteractionArea, endpoint: 'interaction-area.html' },
+ { name: ExampleLinkName.LazyPlayer, endpoint: 'lazy-player.html' },
{ name: ExampleLinkName.MultiplePlayers, endpoint: 'multiple-players.html' },
{ name: ExampleLinkName.Playlist, endpoint: 'playlist.html' },
{ name: ExampleLinkName.PlaylistByTag, endpoint: 'playlist-by-tag-captions.html' },
From 494af4ac6d0f7a81416ad5830439774dda0c0757 Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Tue, 31 Mar 2026 14:08:00 +0300
Subject: [PATCH 05/10] chore: e2e
---
test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts | 12 ++++++------
test/e2e/specs/ESM/linksConsoleErrorsEsmPage.spec.ts | 2 +-
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
index 1a7337b25..9d32f692b 100644
--- a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
+++ b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
@@ -7,16 +7,16 @@ import { waitForPageToLoadWithTimeout } from '../../src/helpers/waitForPageToLoa
const link = getEsmLinkByName(ExampleLinkName.LazyPlayer);
-vpTest('Given lazy-loading page, when load button clicked, then player loads on demand', async ({ page, pomPages }) => {
+vpTest('Given lazy-player page, when load button clicked, then player loads on demand', async ({ page, pomPages }) => {
await page.goto(ESM_URL);
await pomPages.mainPage.clickLinkByName(link.name);
await waitForPageToLoadWithTimeout(page, 5000);
- const results = await page.locator('#results').textContent();
- expect(results).toContain('Lazy stub imported');
- expect(results).not.toContain('Error');
+ const overlay = page.locator('.cld-lazy-preactivate-overlay');
+ await expect(overlay.first()).toBeVisible({ timeout: 5000 });
- await page.locator('#load-btn').click();
+ await page.locator('#btn-load').click();
- await expect(page.locator('#results')).toContainText('Player loaded and created', { timeout: 10000 });
+ await expect(page.locator('.video-js').first()).toBeVisible({ timeout: 10000 });
+ await expect(overlay.first()).not.toBeVisible({ timeout: 5000 });
});
diff --git a/test/e2e/specs/ESM/linksConsoleErrorsEsmPage.spec.ts b/test/e2e/specs/ESM/linksConsoleErrorsEsmPage.spec.ts
index f840aed8e..91f563f03 100644
--- a/test/e2e/specs/ESM/linksConsoleErrorsEsmPage.spec.ts
+++ b/test/e2e/specs/ESM/linksConsoleErrorsEsmPage.spec.ts
@@ -25,7 +25,7 @@ for (const link of ESM_LINKS) {
*/
vpTest('ESM page Link count test', async ({ page }) => {
await page.goto(ESM_URL);
- const expectedNumberOfLinks = 43;
+ const expectedNumberOfLinks = 44;
const numberOfLinks = await page.getByRole('link').count();
expect(numberOfLinks).toBe(expectedNumberOfLinks);
});
From 6ceccbf3aa564b4353e2e59b695f3089b48c14d0 Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Tue, 31 Mar 2026 14:28:21 +0300
Subject: [PATCH 06/10] chore: e2e
---
test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
index 9d32f692b..48f7c255d 100644
--- a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
+++ b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
@@ -17,6 +17,6 @@ vpTest('Given lazy-player page, when load button clicked, then player loads on d
await page.locator('#btn-load').click();
- await expect(page.locator('.video-js').first()).toBeVisible({ timeout: 10000 });
- await expect(overlay.first()).not.toBeVisible({ timeout: 5000 });
+ await expect(page.locator('#player.video-js')).toBeVisible({ timeout: 10000 });
+ await expect(overlay).toHaveCount(1, { timeout: 5000 });
});
From 8dbee2582e8fcfd234cb6ba8b2b7759334641f62 Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Sun, 5 Apr 2026 16:23:05 +0300
Subject: [PATCH 07/10] fix: getCloudinaryConfigFromOptions
---
src/utils/cloudinary-config-from-options.js | 12 ++++++
src/utils/fetch-config.js | 13 +------
src/video-player.utils.js | 5 +--
.../cloudinary-config-from-options.test.js | 38 +++++++++++++++++++
4 files changed, 53 insertions(+), 15 deletions(-)
create mode 100644 src/utils/cloudinary-config-from-options.js
create mode 100644 test/unit/cloudinary-config-from-options.test.js
diff --git a/src/utils/cloudinary-config-from-options.js b/src/utils/cloudinary-config-from-options.js
new file mode 100644
index 000000000..21839b880
--- /dev/null
+++ b/src/utils/cloudinary-config-from-options.js
@@ -0,0 +1,12 @@
+import pick from 'lodash/pick';
+import { CLOUDINARY_CONFIG_PARAM } from '../video-player.const';
+import { convertKeysToSnakeCase } from './object';
+
+export const getCloudinaryConfigFromOptions = (options) => {
+ if (options.cloudinaryConfig) {
+ return options.cloudinaryConfig;
+ }
+
+ const snakeCaseCloudinaryConfig = pick(convertKeysToSnakeCase(options), CLOUDINARY_CONFIG_PARAM);
+ return Object.assign({}, snakeCaseCloudinaryConfig);
+};
diff --git a/src/utils/fetch-config.js b/src/utils/fetch-config.js
index 8de092d71..4ce4cd1f9 100644
--- a/src/utils/fetch-config.js
+++ b/src/utils/fetch-config.js
@@ -1,10 +1,8 @@
-import pick from 'lodash/pick';
import { defaultProfiles } from 'cloudinary-video-player-profiles';
import { isRawUrl, getCloudinaryUrlPrefix } from '../plugins/cloudinary/url-helpers';
-import { CLOUDINARY_CONFIG_PARAM } from '../video-player.const';
import { utf8ToBase64 } from '../utils/utf8Base64';
import { appendQueryParams } from './querystring';
-import { convertKeysToSnakeCase } from './object';
+import { getCloudinaryConfigFromOptions } from './cloudinary-config-from-options';
const isDefaultProfile = (profileName) => !!defaultProfiles.find(({ name }) => profileName === name);
@@ -18,15 +16,6 @@ const getDefaultProfileConfig = (profileName) => {
return profile.config;
};
-const getCloudinaryConfigFromOptions = (options) => {
- if (options.cloudinaryConfig) {
- return options.cloudinaryConfig;
- }
-
- const snakeCaseCloudinaryConfig = pick(convertKeysToSnakeCase(options), CLOUDINARY_CONFIG_PARAM);
- return Object.assign({}, snakeCaseCloudinaryConfig);
-};
-
const fetchConfig = async (options) => {
const { profile, publicId, type = 'upload', videoConfig, allowUsageReport = true } = options;
diff --git a/src/video-player.utils.js b/src/video-player.utils.js
index 45c97080d..52320b99d 100644
--- a/src/video-player.utils.js
+++ b/src/video-player.utils.js
@@ -1,5 +1,4 @@
import videojs from 'video.js';
-import pick from 'lodash/pick';
import Utils from './utils';
import {
PLAYER_PARAMS,
@@ -9,7 +8,7 @@ import {
AUTO_PLAY_MODE
} from './video-player.const';
import isString from 'lodash/isString';
-import { convertKeysToSnakeCase } from './utils/object';
+import { getCloudinaryConfigFromOptions } from './utils/cloudinary-config-from-options';
/*
* Used to escape element identifiers that begin with certain
@@ -84,7 +83,7 @@ export const extractOptions = (elem, options) => {
// Extract cloudinaryConfig from playerOptions if not explicitly provided
if (!options.cloudinaryConfig) {
- const snakeCaseCloudinaryConfig = pick(convertKeysToSnakeCase(options), CLOUDINARY_CONFIG_PARAM);
+ const snakeCaseCloudinaryConfig = getCloudinaryConfigFromOptions(options);
if (Object.keys(snakeCaseCloudinaryConfig).length > 0) {
options.cloudinaryConfig = snakeCaseCloudinaryConfig;
}
diff --git a/test/unit/cloudinary-config-from-options.test.js b/test/unit/cloudinary-config-from-options.test.js
new file mode 100644
index 000000000..a9474c97b
--- /dev/null
+++ b/test/unit/cloudinary-config-from-options.test.js
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest';
+import { getCloudinaryConfigFromOptions } from '../../src/utils/cloudinary-config-from-options';
+
+describe('getCloudinaryConfigFromOptions', () => {
+ it('returns explicit cloudinaryConfig when set', () => {
+ const cfg = { cloud_name: 'demo', secure: false };
+ expect(getCloudinaryConfigFromOptions({ cloudinaryConfig: cfg, cloudName: 'other' })).toBe(cfg);
+ });
+
+ it('maps camelCase top-level keys to snake_case cloudinary fields', () => {
+ expect(
+ getCloudinaryConfigFromOptions({
+ cloudName: 'demo',
+ secure: false,
+ publicId: 'x'
+ })
+ ).toEqual({
+ cloud_name: 'demo',
+ secure: false
+ });
+ });
+
+ it('keeps snake_case keys that are in CLOUDINARY_CONFIG_PARAM', () => {
+ expect(
+ getCloudinaryConfigFromOptions({
+ cloud_name: 'demo',
+ cname: 'media.example.com'
+ })
+ ).toEqual({
+ cloud_name: 'demo',
+ cname: 'media.example.com'
+ });
+ });
+
+ it('returns empty object when no cloudinary params', () => {
+ expect(getCloudinaryConfigFromOptions({ publicId: 'x', profile: 'p' })).toEqual({});
+ });
+});
From cd9aec3f268d1b7a5bf6a38ed92dee059da6e90c Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Mon, 6 Apr 2026 15:16:21 +0300
Subject: [PATCH 08/10] chore: naming
---
src/assets/styles/main.scss | 2 +-
src/utils/lazy-player.js | 14 +++++++-------
test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts | 2 +-
test/unit/lazy-bootstrap.test.js | 6 +++---
4 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/src/assets/styles/main.scss b/src/assets/styles/main.scss
index 9a470bbb4..466bebe0a 100644
--- a/src/assets/styles/main.scss
+++ b/src/assets/styles/main.scss
@@ -76,7 +76,7 @@ $icon-font-path: '../icon-font' !default;
text-shadow: none;
}
- &.cld-lazy-preactivate-overlay {
+ &.cld-lazy-player {
width: auto;
height: auto;
}
diff --git a/src/utils/lazy-player.js b/src/utils/lazy-player.js
index 2fad68260..3bc947c44 100644
--- a/src/utils/lazy-player.js
+++ b/src/utils/lazy-player.js
@@ -52,7 +52,7 @@ export const loadPlayer = ({ overlayRoot, videoElement, options, ready }) => {
return import('../video-player.js').then((m) => m.createVideoPlayer(videoElement, options, ready));
};
-const PREACTIVATE_OVERLAY_CLASS = 'cld-lazy-preactivate-overlay';
+const LAZY_PLAYER_CLASS = 'cld-lazy-player';
const isLightSkin = (videoElement, options) => {
const cls = videoElement.className || '';
@@ -95,7 +95,7 @@ export const lazyBootstrap = async (elem, options, ready) => {
const light = isLightSkin(videoElement, options);
const overlayRoot = document.createElement('div');
- overlayRoot.classList.add('cld-video-player', 'video-js', PREACTIVATE_OVERLAY_CLASS);
+ overlayRoot.classList.add('cld-video-player', 'video-js', LAZY_PLAYER_CLASS);
overlayRoot.classList.add(light ? 'cld-video-player-skin-light' : 'cld-video-player-skin-dark');
const colors = options?.colors;
@@ -123,7 +123,7 @@ export const lazyBootstrap = async (elem, options, ready) => {
}
};
- const activate = (activationOpts = {}) => {
+ const activatePlayer = (activationOpts = {}) => {
if (loadPromise) {
return loadPromise;
}
@@ -146,11 +146,11 @@ export const lazyBootstrap = async (elem, options, ready) => {
function onPlayClick(e) {
e.stopPropagation();
- activate({ autoplayFromUserGesture: true });
+ activatePlayer({ autoplayFromUserGesture: true });
}
function onVideoClick() {
- activate({ autoplayFromUserGesture: true });
+ activatePlayer({ autoplayFromUserGesture: true });
}
playBtn.addEventListener('click', onPlayClick);
@@ -161,7 +161,7 @@ export const lazyBootstrap = async (elem, options, ready) => {
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
- activate({});
+ activatePlayer({});
}
});
},
@@ -172,7 +172,7 @@ export const lazyBootstrap = async (elem, options, ready) => {
const stub = {
source: () => stub,
- loadPlayer: () => activate({ autoplayFromUserGesture: true })
+ loadPlayer: () => activatePlayer({ autoplayFromUserGesture: true })
};
return stub;
diff --git a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
index 48f7c255d..dabe04659 100644
--- a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
+++ b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
@@ -12,7 +12,7 @@ vpTest('Given lazy-player page, when load button clicked, then player loads on d
await pomPages.mainPage.clickLinkByName(link.name);
await waitForPageToLoadWithTimeout(page, 5000);
- const overlay = page.locator('.cld-lazy-preactivate-overlay');
+ const overlay = page.locator('.cld-lazy-player');
await expect(overlay.first()).toBeVisible({ timeout: 5000 });
await page.locator('#btn-load').click();
diff --git a/test/unit/lazy-bootstrap.test.js b/test/unit/lazy-bootstrap.test.js
index 529eeaf6c..1931683ed 100644
--- a/test/unit/lazy-bootstrap.test.js
+++ b/test/unit/lazy-bootstrap.test.js
@@ -47,7 +47,7 @@ describe('lazy-bootstrap', () => {
const video = document.getElementById('lazy-vid');
expect(video.poster).toContain('dog');
- const overlay = document.querySelector('.cld-lazy-preactivate-overlay');
+ const overlay = document.querySelector('.cld-lazy-player');
expect(overlay).toBeTruthy();
expect(overlay.classList.contains('cld-video-player')).toBe(true);
expect(overlay.classList.contains('cld-video-player-skin-dark')).toBe(true);
@@ -63,7 +63,7 @@ describe('lazy-bootstrap', () => {
document.body.innerHTML = '';
await lazyBootstrap('lazy-vid', { lazy: true, cloudName: 'demo', publicId: 'dog' });
const video = document.getElementById('lazy-vid');
- const overlay = document.querySelector('.cld-lazy-preactivate-overlay');
+ const overlay = document.querySelector('.cld-lazy-player');
expect(video.classList.contains('cld-video-player-skin-light')).toBe(true);
expect(overlay.classList.contains('cld-video-player-skin-light')).toBe(true);
expect(video.classList.contains('cld-video-player-skin-dark')).toBe(false);
@@ -76,7 +76,7 @@ describe('lazy-bootstrap', () => {
publicId: 'dog',
colors: { base: '#112233', accent: '#aabbcc', text: '#ffeedd' }
});
- const overlay = document.querySelector('.cld-lazy-preactivate-overlay');
+ const overlay = document.querySelector('.cld-lazy-player');
expect(overlay.style.getPropertyValue('--color-base').trim()).toBe('#112233');
expect(overlay.style.getPropertyValue('--color-accent').trim()).toBe('#aabbcc');
expect(overlay.style.getPropertyValue('--color-text').trim()).toBe('#ffeedd');
From 69c3b42a026dd49b1709a72a3d1a2e9b03d229d8 Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Mon, 6 Apr 2026 15:23:03 +0300
Subject: [PATCH 09/10] chore: examples
---
docs/es-modules/schedule.html | 4 ++--
docs/schedule.html | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/es-modules/schedule.html b/docs/es-modules/schedule.html
index c506ff5cc..6d55f1ae5 100644
--- a/docs/es-modules/schedule.html
+++ b/docs/es-modules/schedule.html
@@ -44,10 +44,10 @@ Schedule
Example Code (ESM):
-import { videoPlayer } from 'cloudinary-video-player';
+import { player } from 'cloudinary-video-player';
import 'cloudinary-video-player/cld-video-player.min.css';
-videoPlayer('player', {
+player('player', {
cloudName: 'demo',
publicId: 'sea_turtle',
schedule: {
diff --git a/docs/schedule.html b/docs/schedule.html
index 313218dab..12e15b1b3 100644
--- a/docs/schedule.html
+++ b/docs/schedule.html
@@ -97,7 +97,7 @@ Schedule
Example Code:
-cloudinary.videoPlayer('player', {
+cloudinary.player('player', {
cloudName: 'demo',
publicId: 'sea_turtle',
schedule: {
From 455f4deb9e45c420e23b8b2924b5940d0f327b97 Mon Sep 17 00:00:00 2001
From: Tsachi Shlidor
Date: Mon, 6 Apr 2026 16:17:02 +0300
Subject: [PATCH 10/10] chore: lazy posters
---
src/utils/bootstrap-poster-url.js | 12 ---------
src/utils/lazy-player.js | 18 ++++++++++---
src/utils/poster-url.js | 36 +++++++++++++++++++-------
test/unit/bootstrap-poster-url.test.js | 25 ------------------
test/unit/poster-url.test.js | 29 +++++++++++++++++----
5 files changed, 65 insertions(+), 55 deletions(-)
delete mode 100644 src/utils/bootstrap-poster-url.js
delete mode 100644 test/unit/bootstrap-poster-url.test.js
diff --git a/src/utils/bootstrap-poster-url.js b/src/utils/bootstrap-poster-url.js
deleted file mode 100644
index f362b642e..000000000
--- a/src/utils/bootstrap-poster-url.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export const getPosterUrl = async (options) => {
- if (typeof options?.poster === 'string' && options.poster.length > 0) {
- return options.poster;
- }
- const cloudName = options?.cloudName || options?.cloud_name || options?.cloudinaryConfig?.cloud_name;
- const publicId = options?.publicId || options?.sourceOptions?.publicId;
- if (cloudName && publicId) {
- const { buildPosterUrl } = await import('./poster-url');
- return buildPosterUrl(cloudName, publicId, options?.cloudinaryConfig || { cloud_name: cloudName });
- }
- throw new Error('lazy requires a poster URL or cloudName and publicId');
-};
diff --git a/src/utils/lazy-player.js b/src/utils/lazy-player.js
index 3bc947c44..605da40dd 100644
--- a/src/utils/lazy-player.js
+++ b/src/utils/lazy-player.js
@@ -1,7 +1,9 @@
-import { getPosterUrl } from './bootstrap-poster-url';
-
const FLUID_CLASS = 'cld-fluid';
+/** Same condition as `getPosterUrl` in `poster-url.js` (explicit string `poster`); skips the `cld-poster-url` async chunk. */
+const hasExplicitPoster = (options) =>
+ typeof options?.poster === 'string' && options.poster.length > 0;
+
export const getVideoElement = (elem) => {
if (typeof elem === 'string') {
let id = elem;
@@ -79,9 +81,19 @@ export const shouldUseLazyBootstrap = (options) => !!options?.lazy;
export const shouldLoadOnScroll = (lazy) =>
lazy && typeof lazy === 'object' && lazy.loadOnScroll === true;
+/**
+ * Renders the lazy placeholder (poster, big-play) before the main player chunk loads.
+ *
+ * @param {string|HTMLVideoElement} elem
+ * @param {object} options
+ * @param {function} [ready] - Passed through when the full player loads.
+ * @returns {Promise<{ source: function, loadPlayer: function }>}
+ */
export const lazyBootstrap = async (elem, options, ready) => {
const videoElement = getVideoElement(elem);
- const posterUrl = await getPosterUrl(options);
+ const posterUrl = hasExplicitPoster(options)
+ ? options.poster
+ : (await import(/* webpackChunkName: "cld-poster-url" */ './poster-url')).getPosterUrl(options);
const loadOnScroll = shouldLoadOnScroll(options.lazy);
const { hadControls } = preparePlayerPlaceholder(videoElement, posterUrl, {
diff --git a/src/utils/poster-url.js b/src/utils/poster-url.js
index 765c3e546..339900a68 100644
--- a/src/utils/poster-url.js
+++ b/src/utils/poster-url.js
@@ -1,17 +1,14 @@
/**
- * Minimal Cloudinary poster URL builder for video first frame.
- * Used by schedule bootstrap when outside schedule (no full player loaded).
+ * Cloudinary video poster URLs for bootstrap paths (lazy shell, schedule image) where the full player is not loaded.
+ * Default delivery matches VideoSource poster defaults: video resource, JPG still.
*/
import { getCloudinaryUrlPrefix } from './cloudinary-url-prefix';
-const POSTER_TRANSFORMATION = 'so_0,f_auto,q_auto';
-
/**
- * Build Cloudinary video poster (first frame) URL.
- * @param {string} cloudName - Cloudinary cloud name
- * @param {string} publicId - Video public ID
- * @param {object} [cloudinaryConfig] - Optional: secure, private_cdn, cdn_subdomain, cname, secure_distribution
- * @returns {string} Poster image URL
+ * @param {string} cloudName
+ * @param {string} publicId
+ * @param {object} [cloudinaryConfig] - Same shape as player `cloudinaryConfig` (e.g. private_cdn, cname, secure).
+ * @returns {string}
*/
export const buildPosterUrl = (cloudName, publicId, cloudinaryConfig = {}) => {
const config = {
@@ -21,5 +18,24 @@ export const buildPosterUrl = (cloudName, publicId, cloudinaryConfig = {}) => {
};
const prefix = getCloudinaryUrlPrefix(config);
- return `${prefix}/video/upload/${POSTER_TRANSFORMATION}/${publicId}`;
+ return `${prefix}/video/upload/${publicId}.jpg`;
+};
+
+/**
+ * Resolves the poster URL for lazy bootstrap from player options.
+ *
+ * @param {object} options - Player options (`poster`, `cloudName` / `cloud_name`, `publicId` / `sourceOptions.publicId`, `cloudinaryConfig`).
+ * @returns {string}
+ * @throws {Error} When no explicit `poster` string and cloud name + public id are missing.
+ */
+export const getPosterUrl = (options) => {
+ if (typeof options?.poster === 'string' && options.poster.length > 0) {
+ return options.poster;
+ }
+ const cloudName = options?.cloudName || options?.cloud_name || options?.cloudinaryConfig?.cloud_name;
+ const publicId = options?.publicId || options?.sourceOptions?.publicId;
+ if (cloudName && publicId) {
+ return buildPosterUrl(cloudName, publicId, options?.cloudinaryConfig || { cloud_name: cloudName });
+ }
+ throw new Error('lazy requires a poster URL or cloudName and publicId');
};
diff --git a/test/unit/bootstrap-poster-url.test.js b/test/unit/bootstrap-poster-url.test.js
deleted file mode 100644
index 71109f414..000000000
--- a/test/unit/bootstrap-poster-url.test.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { getPosterUrl } from '../../src/utils/bootstrap-poster-url';
-
-describe('bootstrap-poster-url', () => {
- it('returns poster string when set', async () => {
- expect(await getPosterUrl({ poster: 'https://example.com/p.jpg' })).toBe(
- 'https://example.com/p.jpg'
- );
- });
-
- it('builds URL from cloudName and publicId', async () => {
- const url = await getPosterUrl({
- cloudName: 'demo',
- publicId: 'dog'
- });
- expect(url).toContain('demo');
- expect(url).toContain('dog');
- expect(url).toContain('video/upload');
- });
-
- it('throws when no poster or ids', async () => {
- await expect(getPosterUrl({ cloudName: 'demo' })).rejects.toThrow(
- 'lazy requires a poster URL or cloudName and publicId'
- );
- });
-});
diff --git a/test/unit/poster-url.test.js b/test/unit/poster-url.test.js
index bcfd7643c..ddee5fade 100644
--- a/test/unit/poster-url.test.js
+++ b/test/unit/poster-url.test.js
@@ -1,15 +1,12 @@
-import { buildPosterUrl } from '../../src/utils/poster-url';
+import { buildPosterUrl, getPosterUrl } from '../../src/utils/poster-url';
describe('buildPosterUrl', () => {
it('builds correct Cloudinary poster URL for cloudName and publicId', () => {
const url = buildPosterUrl('demo', 'sample_video');
expect(url).toContain('res.cloudinary.com');
expect(url).toContain('demo');
- expect(url).toContain('sample_video');
+ expect(url).toContain('sample_video.jpg');
expect(url).toContain('/video/upload/');
- expect(url).toContain('so_0');
- expect(url).toContain('f_auto');
- expect(url).toContain('q_auto');
});
it('uses https by default', () => {
@@ -22,3 +19,25 @@ describe('buildPosterUrl', () => {
expect(url).toContain('sample');
});
});
+
+describe('getPosterUrl', () => {
+ it('returns poster string when set', () => {
+ expect(getPosterUrl({ poster: 'https://example.com/p.jpg' })).toBe('https://example.com/p.jpg');
+ });
+
+ it('builds URL from cloudName and publicId', () => {
+ const url = getPosterUrl({
+ cloudName: 'demo',
+ publicId: 'dog'
+ });
+ expect(url).toContain('demo');
+ expect(url).toContain('dog');
+ expect(url).toContain('video/upload');
+ });
+
+ it('throws when no poster or ids', () => {
+ expect(() => getPosterUrl({ cloudName: 'demo' })).toThrow(
+ 'lazy requires a poster URL or cloudName and publicId'
+ );
+ });
+});