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/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 @@
Uses an explicit poster URL. Custom accent color and logo configured.
+ Click the player or the button above to initialize. The button calls player.loadPlayer().
+
No poster URL - built automatically from cloudName and publicId. Custom colors configured.
+ Scroll down - the player loads automatically when it enters the viewport. +
+ +
+import { player as createPlayer } from 'cloudinary-video-player/lazy';
+import 'cloudinary-video-player/cld-video-player.min.css';
+
+// With explicit poster URL
+const player = await createPlayer('my-video', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ 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();
+
+// Auto-built poster from cloudName + publicId
+createPlayer('my-video', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ lazy: { loadOnScroll: true },
+ colors: { base: '#0d1b2a', accent: '#00b4d8' }
+});
+
+
-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/index.html b/docs/index.html
index 242a58e4c..32bb11ec4 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -68,6 +68,7 @@ Some code examples:
Force HLS Subtitles
HDR
Interaction Area
+ Lazy player
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..2378061a7
--- /dev/null
+++ b/docs/lazy-player.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+ Cloudinary Video Player - Lazy player
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cloudinary Video Player
+ Lazy player (with poster URL)
+ Uses an explicit poster URL. Custom accent color and logo configured.
+
+
+
+
+
+ Click the player or the button above to initialize. The button calls player.loadPlayer().
+
+
+ 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.
+
+
+
+ ↓ ↓ ↓ ↓ ↓ ↓ ↓
+
+
+
+
+ Example code:
+
+
+ // With explicit poster URL
+ const player = await cloudinary.player('my-video', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ 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();
+
+ // Auto-built poster from cloudName + publicId
+ cloudinary.player('my-video', {
+ cloudName: 'demo',
+ publicId: 'sea_turtle',
+ lazy: { loadOnScroll: true },
+ colors: { base: '#0d1b2a', accent: '#00b4d8' }
+ });
+
+
+
+
+
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: {
diff --git a/src/assets/styles/main.scss b/src/assets/styles/main.scss
index 5534680ca..466bebe0a 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-player {
+ 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/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/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..605da40dd
--- /dev/null
+++ b/src/utils/lazy-player.js
@@ -0,0 +1,191 @@
+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;
+ if (id.indexOf('#') === 0) id = id.slice(1);
+ try {
+ elem = document.querySelector(`#${CSS.escape(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 LAZY_PLAYER_CLASS = 'cld-lazy-player';
+
+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;
+
+/**
+ * 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 = hasExplicitPoster(options)
+ ? options.poster
+ : (await import(/* webpackChunkName: "cld-poster-url" */ './poster-url')).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', LAZY_PLAYER_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 activatePlayer = (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();
+ activatePlayer({ autoplayFromUserGesture: true });
+ }
+
+ function onVideoClick() {
+ activatePlayer({ 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) {
+ activatePlayer({});
+ }
+ });
+ },
+ { rootMargin: '0px', threshold: 0.25 }
+ );
+ observer.observe(videoElement);
+ }
+
+ const stub = {
+ source: () => stub,
+ loadPlayer: () => activatePlayer({ autoplayFromUserGesture: true })
+ };
+
+ return stub;
+};
diff --git a/src/utils/player-api.js b/src/utils/player-api.js
index 6113aa991..c09d94713 100644
--- a/src/utils/player-api.js
+++ b/src/utils/player-api.js
@@ -1,8 +1,9 @@
-import { scheduleBootstrap, shouldUseScheduleBootstrap, getElementForSchedule } 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 {
@@ -14,8 +15,15 @@ export const createAsyncPlayer = async (id, playerOptions, ready, createFn) => {
}
})();
- if (shouldUseScheduleBootstrap(opts)) {
- return scheduleBootstrap(id, opts);
+ if (opts?.schedule?.weekly) {
+ const { shouldUseScheduleBootstrap, scheduleBootstrap } = await import('./schedule');
+ if (shouldUseScheduleBootstrap(opts)) {
+ return scheduleBootstrap(id, opts, ready);
+ }
+ }
+
+ if (shouldUseLazyBootstrap(opts)) {
+ return lazyBootstrap(id, opts, ready);
}
return createFn(videoElement, opts, ready);
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/src/utils/schedule.js b/src/utils/schedule.js
index 69184ee4d..0a1b9634a 100644
--- a/src/utils/schedule.js
+++ b/src/utils/schedule.js
@@ -1,9 +1,10 @@
/**
- * 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 +43,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 = async (elem, options, ready) => {
+ const videoElement = getVideoElement(elem);
const cloudName = getCloudNameFromOptions(options);
const publicId = getPublicIdFromOptions(options);
@@ -53,15 +55,18 @@ export const scheduleBootstrap = (elem, options) => {
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);
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/src/video-player.utils.js b/src/video-player.utils.js
index 637a09301..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,14 +8,13 @@ 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
* 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 +39,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;
}
@@ -85,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/e2e/specs/ESM/esmLazyLoadingPage.spec.ts b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
index 055b97170..dabe04659 100644
--- a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
+++ b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts
@@ -5,18 +5,18 @@ 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 }) => {
+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-player');
+ 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('#player.video-js')).toBeVisible({ timeout: 10000 });
+ await expect(overlay).toHaveCount(1, { 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);
});
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' },
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({});
+ });
+});
diff --git a/test/unit/lazy-bootstrap.test.js b/test/unit/lazy-bootstrap.test.js
new file mode 100644
index 000000000..1931683ed
--- /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', async () => {
+ const stub = await 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-player');
+ 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', async () => {
+ 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-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);
+ });
+
+ it('applies colors option as CSS custom properties on overlay wrapper', async () => {
+ await lazyBootstrap('lazy-vid', {
+ lazy: true,
+ cloudName: 'demo',
+ publicId: 'dog',
+ colors: { base: '#112233', accent: '#aabbcc', text: '#ffeedd' }
+ });
+ 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');
+ });
+ });
+});
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/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'
+ );
+ });
+});
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/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');
+ };
+}
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 {