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 @@

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..e7dc0966f --- /dev/null +++ b/docs/es-modules/lazy-player.html @@ -0,0 +1,119 @@ + + + + + Cloudinary Video Player - Lazy player (ESM) + + + + + +
    + +

    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:

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