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.

    @@ -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'
    +    );
    +  });
    +});