diff --git a/.config/vitest.config.ts b/.config/vitest.config.ts index 8ecdc7f32..37abf8271 100644 --- a/.config/vitest.config.ts +++ b/.config/vitest.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ }, resolve: { alias: { + '~': path.resolve(process.cwd(), './src'), '@': path.resolve(process.cwd(), './src'), 'src': path.resolve(process.cwd(), './src'), 'assets': path.resolve(process.cwd(), './src/assets'), diff --git a/docs/es-modules/adaptive-streaming.html b/docs/es-modules/adaptive-streaming.html index ace1a0428..0f7640dc0 100644 --- a/docs/es-modules/adaptive-streaming.html +++ b/docs/es-modules/adaptive-streaming.html @@ -107,8 +107,6 @@

HLS with highQuality

+ + + + diff --git a/docs/es-modules/index.html b/docs/es-modules/index.html index c7d438175..e6fe80749 100644 --- a/docs/es-modules/index.html +++ b/docs/es-modules/index.html @@ -71,6 +71,7 @@

Code examples:

  • Profiles
  • Raw URL
  • Recommendations
  • +
  • Schedule (weekly time slots)
  • Seek Thumbnails
  • Share & Download
  • Shoppable Videos
  • @@ -82,6 +83,12 @@

    Code examples:

  • VR/360 Videos

  • /all build
  • +
    +

    Bundler Tests

    +
  • Entry Points (all exports)
  • +
  • Tree Shaking
  • +
  • Lazy Loading
  • +
  • Multiple Entry Points
  • diff --git a/docs/es-modules/interaction-area.html b/docs/es-modules/interaction-area.html index b33674b81..0b27a3818 100644 --- a/docs/es-modules/interaction-area.html +++ b/docs/es-modules/interaction-area.html @@ -86,7 +86,7 @@

    Auto-zoom video cropping

    + + + + diff --git a/docs/es-modules/multiple-entry-points.html b/docs/es-modules/multiple-entry-points.html new file mode 100644 index 000000000..7119246ed --- /dev/null +++ b/docs/es-modules/multiple-entry-points.html @@ -0,0 +1,98 @@ + + + + + Cloudinary Video Player - Multiple Entry Points Test + + + + + +
    + +

    Cloudinary Video Player

    +

    Bundler Test: Multiple Entry Points

    + +

    + Two entry points (/ and /all) used on the same page. + Verifies shared chunks load once and both players work without conflicts. +

    + +

    Results:

    +
    
    +
    +      

    Basic player (default entry):

    + + +

    Player with chapters (/all entry):

    + + +

    What to check in DevTools:

    + +
    + + + + + + diff --git a/docs/es-modules/package.json b/docs/es-modules/package.json index e4b21064f..498fa587c 100644 --- a/docs/es-modules/package.json +++ b/docs/es-modules/package.json @@ -3,8 +3,8 @@ "private": true, "version": "0.0.1", "scripts": { - "prepare-player": "cd ../../ && npm i && npm run build-all", - "install-player": "npm i ../../ --no-save", + "prepare-player": "cd ../.. && npm i && npm run build-es", + "install-player": "npm i ../.. --no-save", "prepare": "npm run prepare-player && npm run install-player", "update-edge": "npm i", "start": "vite", diff --git a/docs/es-modules/playlist-by-tag.html b/docs/es-modules/playlist-by-tag.html index 35a919ce7..318a9d240 100644 --- a/docs/es-modules/playlist-by-tag.html +++ b/docs/es-modules/playlist-by-tag.html @@ -41,7 +41,7 @@

    Playlist by tag (with captions)

    + + + + diff --git a/docs/es-modules/share-plugin.html b/docs/es-modules/share-plugin.html index 8841e8761..4a9514e21 100644 --- a/docs/es-modules/share-plugin.html +++ b/docs/es-modules/share-plugin.html @@ -54,7 +54,7 @@

    Share and Download

    import { videoPlayer } from 'cloudinary-video-player'; import 'cloudinary-video-player/cld-video-player.min.css'; import 'cloudinary-video-player/adaptive-streaming'; - import 'cloudinary-video-player/share'; + // Player with download enabled for all sources const player1 = videoPlayer('player1', { diff --git a/docs/es-modules/shoppable.html b/docs/es-modules/shoppable.html index 312050f9b..33a8af6fc 100644 --- a/docs/es-modules/shoppable.html +++ b/docs/es-modules/shoppable.html @@ -37,7 +37,7 @@

    Shoppable Videos

    + + + + diff --git a/docs/es-modules/vast-vpaid.html b/docs/es-modules/vast-vpaid.html index 38292dc3d..0226bd889 100644 --- a/docs/es-modules/vast-vpaid.html +++ b/docs/es-modules/vast-vpaid.html @@ -52,9 +52,6 @@

    Playlist with Ads

    --> - + @@ -76,6 +76,7 @@

    Some code examples:

  • Profiles
  • Raw URL
  • Recommendations
  • +
  • Schedule (weekly time slots)
  • Seek Thumbnails
  • Share & Download
  • Shoppable Videos
  • diff --git a/docs/playlist.html b/docs/playlist.html index 9d07381c7..b4b954dc7 100644 --- a/docs/playlist.html +++ b/docs/playlist.html @@ -21,10 +21,10 @@ --> - + --> - + + + + + +
    + +

    Cloudinary Video Player

    +

    Schedule (Weekly Time Slots)

    + +

    + Video plays only during configured time slots. Outside schedule, a poster image is shown. + Call loadPlayer() on the stub to load the player on demand (e.g. on click). +

    + +
    + + + +
    + +

    Schedule

    +
    + +
    + +

    Example Code:

    +
    
    +cloudinary.videoPlayer('player', {
    +  cloudName: 'demo',
    +  publicId: 'sea_turtle',
    +  schedule: {
    +    weekly: [
    +      { day: 'monday', start: '09:00', duration: 8 },
    +      { day: 'tuesday', start: '09:00', duration: 8 }
    +    ]
    +  }
    +});
    +    
    +
    + + diff --git a/docs/scripts.js b/docs/scripts.js index a6abbcb59..39c8d6dcd 100644 --- a/docs/scripts.js +++ b/docs/scripts.js @@ -118,8 +118,11 @@ var loadStyle = function (source, ver) { } function initPlayerExamples() { + var scriptUrl = new URL(document.currentScript.src, window.location); + var lazy = scriptUrl.searchParams.has('lazy'); + var bundleName = lazy ? '/cld-video-player-lazy' : '/cld-video-player'; loadStyle('/cld-video-player' + (min ? '.min' : '') + '.css', ver); - loadScript('/cld-video-player' + (min ? '.min' : '') + '.js', ver); + loadScript(bundleName + (min ? '.min' : '') + '.js', ver); window.addEventListener('load', function () { diff --git a/package-lock.json b/package-lock.json index e10fb51e6..87ddf91ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,10 @@ "@commitlint/cli": "^19.6.0", "@commitlint/config-conventional": "^19.6.0", "@playwright/test": "1.57.0", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.0", "@types/node": "22.10.1", "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", @@ -65,6 +69,7 @@ "mini-css-extract-plugin": "^2.9.4", "puppeteer": "^22.15.0", "puppeteer-request-spy": "^1.4.0", + "rollup": "^4.28.1", "sass": "^1.94.2", "sass-loader": "^16.0.6", "semver": "^7.7.3", @@ -4756,6 +4761,188 @@ "node": ">=18" } }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", @@ -5487,6 +5674,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/retry": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", @@ -7683,6 +7877,13 @@ "dev": true, "license": "ISC" }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -10818,6 +11019,25 @@ } } }, + "node_modules/git-semver-tags/node_modules/conventional-commits-parser": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", + "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -11978,6 +12198,13 @@ "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", "license": "MIT" }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-network-error": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", @@ -12044,6 +12271,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", diff --git a/package.json b/package.json index 66861e0f6..f9ef13937 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "git", "url": "https://github.com/cloudinary/cloudinary-video-player.git" }, - "module": "./lib/cld-video-player.js", + "module": "./lib/index.js", "main": "./dist/cld-video-player.min.js", "style": "./dist/cld-video-player.min.css", "types": "./types/cld-video-player.d.ts", @@ -22,9 +22,28 @@ "exports": { ".": { "types": "./types/cld-video-player.d.ts", - "import": "./lib/cld-video-player.js", + "import": "./lib/index.js", "require": "./dist/cld-video-player.min.js" }, + "./videoPlayer": { + "import": "./lib/videoPlayer.js", + "require": "./dist/cld-video-player.min.js" + }, + "./player": { + "import": "./lib/player.js", + "require": "./dist/cld-video-player.min.js" + }, + "./all": { + "import": "./lib/all.js", + "require": "./dist/cld-video-player.min.js" + }, + "./lazy": { + "import": "./lib/lazy.js", + "require": "./dist/cld-video-player-lazy.min.js" + }, + "./cld-video-player.min.css": "./lib/cld-video-player.min.css", + "./shoppable": "./lib/shoppable-widget.js", + "./interaction-areas": "./lib/interaction-areas.service.js", "./*": "./lib/*", "./light/*": "./lib/*" }, @@ -34,7 +53,7 @@ "start": "webpack serve --config webpack/dev.config.js", "build": "WEBPACK_BUILD_MIN=1 webpack --config webpack/build.config.js --progress --color", "build-dev": "webpack --config webpack/build.config.js --progress --color --mode=development", - "build-es": "WEBPACK_BUILD_MIN=1 webpack --config webpack/es6.config.js --progress --color", + "build-es": "rollup -c rollup.esm.config.js", "build-light": "node webpack/copy-light-bundle.js", "build-all": "npm run clean && npm run build && npm run build-dev && npm run build-es && npm run build-light", "analyze": "webpack --config webpack/analyzer.config.js", @@ -62,19 +81,11 @@ "files": [ { "path": "./dist/cld-video-player.min.js", - "maxSize": "137kb" - }, - { - "path": "./lib/cld-video-player.js", - "maxSize": "137kb" - }, - { - "path": "./lib/videoPlayer.js", - "maxSize": "137kb" + "maxSize": "140kb" }, { - "path": "./lib/all.js", - "maxSize": "330kb" + "path": "./dist/cld-video-player-lazy.min.js", + "maxSize": "10kb" } ] }, @@ -96,6 +107,10 @@ "webfontloader": "^1.6.28" }, "devDependencies": { + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.0", "@actions/core": "^1.11.1", "@actions/github": "^6.0.1", "@babel/core": "^7.28.5", @@ -116,6 +131,7 @@ "conventional-changelog-angular": "^8.1.0", "conventional-changelog-cli": "^5.0.0", "conventional-recommended-bump": "^11.2.0", + "rollup": "^4.28.1", "copy-webpack-plugin": "^12.0.2", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.2", diff --git a/rollup.esm.config.js b/rollup.esm.config.js new file mode 100644 index 000000000..82fa2c7bc --- /dev/null +++ b/rollup.esm.config.js @@ -0,0 +1,130 @@ +const path = require('path'); +const { babel } = require('@rollup/plugin-babel'); +const nodeResolve = require('@rollup/plugin-node-resolve'); +const commonjs = require('@rollup/plugin-commonjs'); +const replace = require('@rollup/plugin-replace'); +const { copyFileSync, mkdirSync, existsSync, statSync } = require('fs'); + +const pkg = require('./package.json'); + +const srcDir = path.resolve(__dirname, 'src'); +const outDir = path.resolve(__dirname, 'lib'); + +const STUB_MODULE_ID = '\0stub-scss'; + +/** + * Resolve ~/ imports to src paths (e.g. ~/utils/slicing -> src/utils/slicing.js). + */ +function srcPathAlias() { + return { + name: 'src-path-alias', + resolveId(id) { + if (!id.startsWith('~/')) return null; + if (id.endsWith('.scss') || id.endsWith('.css')) return null; + const resolved = path.join(srcDir, id.slice(2)); + if (resolved.endsWith('.js') || resolved.endsWith('.json')) return resolved; + if (existsSync(resolved) && statSync(resolved).isDirectory()) return path.join(resolved, 'index.js'); + return `${resolved}.js`; + } + }; +} + +/** + * Alias video.js to the core build and expose as window.videojs + * (mirrors webpack expose-loader config). + */ +function videoJsAlias() { + const PROXY_ID = '\0videojs-proxy'; + const corePath = path.resolve(__dirname, 'node_modules/video.js/dist/alt/video.core.js').replace(/\\/g, '/'); + + return { + name: 'video-js-alias', + resolveId(id) { + if (id === 'video.js') { + return PROXY_ID; + } + return null; + }, + load(id) { + if (id === PROXY_ID) { + return [ + `import _vjs from '${corePath}';`, + `if (typeof window !== 'undefined') window.videojs = _vjs;`, + `export default _vjs;` + ].join('\n'); + } + return null; + } + }; +} + +/** + * Stub .scss and .css imports - ESM consumers import CSS separately. + */ +function stubScss() { + return { + name: 'stub-scss', + resolveId(id) { + if (id.endsWith('.scss') || id.endsWith('.css')) { + return STUB_MODULE_ID; + } + return null; + }, + load(id) { + if (id === STUB_MODULE_ID) return 'export default {}'; + return null; + } + }; +} + +module.exports = { + input: { + index: path.join(srcDir, 'index.js'), + videoPlayer: path.join(srcDir, 'index.videoPlayer.js'), + player: path.join(srcDir, 'index.player.js'), + all: path.join(srcDir, 'index.all.js'), + lazy: path.join(srcDir, 'index.lazy.js'), + dash: path.join(srcDir, 'dash.js'), + debug: path.join(srcDir, 'debug.js') + }, + output: { + dir: outDir, + format: 'esm', + entryFileNames: '[name].js', + chunkFileNames: '[name].js' + }, + plugins: [ + replace({ + include: path.join(srcDir, '**'), + VERSION: JSON.stringify(pkg.version), + preventAssignment: true + }), + videoJsAlias(), + srcPathAlias(), + stubScss(), + nodeResolve({ preferBuiltins: false }), + commonjs(), + babel({ + babelHelpers: 'bundled', + configFile: path.join(__dirname, 'babel.config.js'), + exclude: 'node_modules/**' + }), + { + name: 'copy-assets', + writeBundle() { + const schemaSrc = path.join(__dirname, 'src/config/configSchema.json'); + const schemaDest = path.join(outDir, 'config/configSchema.json'); + if (existsSync(schemaSrc)) { + mkdirSync(path.dirname(schemaDest), { recursive: true }); + copyFileSync(schemaSrc, schemaDest); + } + const cssSrc = path.join(__dirname, 'dist/cld-video-player.min.css'); + const cssDest = path.join(__dirname, 'lib/cld-video-player.min.css'); + if (existsSync(cssSrc)) { + mkdirSync(path.dirname(cssDest), { recursive: true }); + copyFileSync(cssSrc, cssDest); + } + } + } + ] +}; diff --git a/src/components/error-display/error-display.js b/src/components/error-display/error-display.js index c78160f3e..27d70db0e 100644 --- a/src/components/error-display/error-display.js +++ b/src/components/error-display/error-display.js @@ -1,5 +1,5 @@ import videojs from 'video.js'; -import { PLAYER_EVENT } from 'utils/consts'; +import { PLAYER_EVENT } from '~/utils/consts'; const ErrorDisplay = videojs.getComponent('ErrorDisplay'); diff --git a/src/components/recommendations-overlay/recommendations-overlay-primary-item.js b/src/components/recommendations-overlay/recommendations-overlay-primary-item.js index 0baf883f7..4406724ea 100644 --- a/src/components/recommendations-overlay/recommendations-overlay-primary-item.js +++ b/src/components/recommendations-overlay/recommendations-overlay-primary-item.js @@ -1,6 +1,6 @@ import videojs from 'video.js'; import RecommendationsOverlayItem from './recommendations-overlay-item'; -import componentUtils from 'components/component-utils'; +import componentUtils from '../component-utils'; // support VJS5 & VJS6 at the same time const dom = videojs.dom || videojs; diff --git a/src/components/shoppable-bar/layout/shoppable-products-overlay.js b/src/components/shoppable-bar/layout/shoppable-products-overlay.js index 3f43d9165..6016ed9d0 100644 --- a/src/components/shoppable-bar/layout/shoppable-products-overlay.js +++ b/src/components/shoppable-bar/layout/shoppable-products-overlay.js @@ -1,7 +1,7 @@ import videojs from 'video.js'; const dom = videojs.dom || videojs; -import { parseTime } from 'utils/time'; -import { find } from 'utils/find'; +import { parseTime } from '~/utils/time'; +import { find } from '~/utils/find'; import { SHOPPABLE_PANEL_HIDDEN_CLASS, SHOPPABLE_PANEL_VISIBLE_CLASS, diff --git a/src/components/shoppable-bar/panel/shoppable-panel.js b/src/components/shoppable-bar/panel/shoppable-panel.js index 1e08eaa8f..53484676a 100644 --- a/src/components/shoppable-bar/panel/shoppable-panel.js +++ b/src/components/shoppable-bar/panel/shoppable-panel.js @@ -1,6 +1,6 @@ import videojs from 'video.js'; import throttle from 'lodash/throttle'; -import { parseTime } from 'utils/time'; +import { parseTime } from '~/utils/time'; import ShoppablePanelItem from './shoppable-panel-item'; import ImageSource from '../../../plugins/cloudinary/models/image-source'; import { diff --git a/src/components/title-bar/title-bar.js b/src/components/title-bar/title-bar.js index 71358d9d7..521fe00b7 100644 --- a/src/components/title-bar/title-bar.js +++ b/src/components/title-bar/title-bar.js @@ -1,8 +1,8 @@ import videojs from 'video.js'; -import 'assets/styles/components/title-bar.scss'; +import '~/assets/styles/components/title-bar.scss'; import componentUtils from '../component-utils'; import { utf8ToBase64 } from '../../utils/utf8Base64'; -import { getCloudinaryUrlPrefix } from 'plugins/cloudinary/common'; +import { getCloudinaryUrlPrefix } from '~/plugins/cloudinary/common'; import { appendQueryParams } from '../../utils/querystring'; // support VJS5 & VJS6 at the same time diff --git a/src/config/configSchema.json b/src/config/configSchema.json index 345d43b6c..c91da5e2c 100644 --- a/src/config/configSchema.json +++ b/src/config/configSchema.json @@ -559,6 +559,25 @@ "type": "boolean", "default": true }, + "schedule": { + "type": "object", + "properties": { + "weekly": { + "type": "array", + "items": { + "type": "object", + "properties": { + "day": { "type": "string" }, + "start": { "type": "string" }, + "duration": { "type": "number" } + }, + "required": ["day", "start", "duration"] + }, + "default": [] + } + }, + "default": {} + }, "videoSources": { "type": "array", "items": { diff --git a/src/dash.js b/src/dash.js new file mode 100644 index 000000000..d1cace838 --- /dev/null +++ b/src/dash.js @@ -0,0 +1,5 @@ +/** + * Side-effect import to register videojs-contrib-dash with Video.js. + * Used for ESM consumers who import 'cloudinary-video-player/dash'. + */ +import 'videojs-contrib-dash'; diff --git a/src/debug.js b/src/debug.js new file mode 100644 index 000000000..a4bf0db7d --- /dev/null +++ b/src/debug.js @@ -0,0 +1,5 @@ +/** + * Side-effect import to preload validators for config validation. + * Used for ESM consumers who import 'cloudinary-video-player/debug'. + */ +import './validators/validators'; diff --git a/src/index.js b/src/index.js index d6b5e2ec7..2e6706511 100644 --- a/src/index.js +++ b/src/index.js @@ -1,72 +1,20 @@ -import 'assets/styles/main.scss'; +import '~/assets/styles/main.scss'; import videojs from 'video.js'; -import VideoPlayer from './video-player'; -import defaults from './config/defaults'; -import { getResolveVideoElement, extractOptions } from './video-player.utils'; -import { fetchAndMergeConfig } from './utils/fetch-config'; +import { createVideoPlayer, createPlayerWithConfig } from './video-player.js'; +import { createAsyncPlayer, createMultiplePlayers, createMultipleSync, setupCloudinaryGlobal } from './utils/player-api'; -const getConfig = (elem, playerOptions = {}) => { - const videoElement = getResolveVideoElement(elem); - const options = extractOptions(videoElement, playerOptions); - - return { videoElement, options }; -}; +export { videojs }; -const mergeDefaults = (options) => videojs.obj.merge({}, defaults, options); +export const videoPlayer = (id, playerOptions = {}, ready) => + createVideoPlayer(id, playerOptions, ready); -export const videoPlayer = (id, playerOptions = {}, ready) => { - const { videoElement, options } = getConfig(id, playerOptions); - if (options.profile) { - console.warn('Profile option requires async initialization. Use cloudinary.player() instead of cloudinary.videoPlayer()'); - } - return new VideoPlayer(videoElement, mergeDefaults(options), ready); -}; +export const videoPlayers = (selector, playerOptions, ready) => + createMultipleSync(selector, playerOptions, ready, videoPlayer); -export const videoPlayers = (selector, playerOptions, ready) => { - const nodeList = document.querySelectorAll(selector); - return [...nodeList].map(node => videoPlayer(node, playerOptions, ready)); -}; +export const player = (id, playerOptions = {}, ready) => + createAsyncPlayer(id, playerOptions, ready, createPlayerWithConfig); -export const player = async (id, playerOptions, ready) => { - const { videoElement, options } = getConfig(id, playerOptions); - - try { - const videoConfig = await fetchAndMergeConfig(options); - return new VideoPlayer(videoElement, mergeDefaults(videoConfig), ready); - } catch (e) { - const videoPlayer = new VideoPlayer(videoElement, mergeDefaults(options)); - videoPlayer.videojs.error('Invalid profile'); - throw e; - } -}; +export const players = (selector, playerOptions, ready) => + createMultiplePlayers(selector, playerOptions, ready, player); -export const players = async (selector, playerOptions, ready) => { - const nodeList = document.querySelectorAll(selector); - return Promise.all([...nodeList].map(node => player(node, playerOptions, ready))); -}; - -const cloudinaryVideoPlayerLegacyConfig = () => { - console.warn( - 'Cloudinary.new() is deprecated and will be removed. Please use cloudinary.videoPlayer() instead.' - ); - return { - videoPlayer, - videoPlayers - }; -}; - -const cloudinary = { - ...(window.cloudinary || {}), - videoPlayer, - videoPlayers, - player, - players, - Cloudinary: { - // Backwards compatibility with SDK v1 - new: cloudinaryVideoPlayerLegacyConfig - } -}; - -window.cloudinary = cloudinary; - -export default cloudinary; +export default setupCloudinaryGlobal({ videoPlayer, videoPlayers, player, players }); diff --git a/src/index.lazy.js b/src/index.lazy.js new file mode 100644 index 000000000..6ba14e523 --- /dev/null +++ b/src/index.lazy.js @@ -0,0 +1,18 @@ +/** + * Lazy entry: tiny initial bundle, player core loads on demand. + * Only player()/players() are available. For sync videoPlayer(), use the full bundle. + */ +import { createAsyncPlayer, createMultiplePlayers, setupCloudinaryGlobal } from './utils/player-api'; + +export const player = (id, playerOptions, ready) => + createAsyncPlayer(id, playerOptions, ready, async (videoElement, opts, r) => { + const { videoPlayer } = await import( + /* webpackChunkName: "cld-player-core" */ './index.js' + ); + return videoPlayer(videoElement, opts, r); + }); + +export const players = (selector, playerOptions, ready) => + createMultiplePlayers(selector, playerOptions, ready, player); + +export default setupCloudinaryGlobal({ player, players }); diff --git a/src/index.umd.js b/src/index.umd.js new file mode 100644 index 000000000..be940d9a2 --- /dev/null +++ b/src/index.umd.js @@ -0,0 +1,39 @@ +/** + * UMD entry: full player bundle for backwards-compatible sync videoPlayer(). + * player() is async with profile and schedule support. + */ +import '~/assets/styles/main.scss'; +import { createVideoPlayer, createPlayerWithConfig } from './video-player'; +import { createAsyncPlayer, createMultiplePlayers, createMultipleSync, setupCloudinaryGlobal } from './utils/player-api'; + +export const videoPlayer = (id, playerOptions = {}, ready) => + createVideoPlayer(id, playerOptions, ready); + +export const videoPlayers = (selector, playerOptions, ready) => + createMultipleSync(selector, playerOptions, ready, videoPlayer); + +export const player = (id, playerOptions = {}, ready) => + createAsyncPlayer(id, playerOptions, ready, createPlayerWithConfig); + +export const players = (selector, playerOptions, ready) => + createMultiplePlayers(selector, playerOptions, ready, player); + +const cloudinaryVideoPlayerLegacyConfig = () => { + console.warn( + 'Cloudinary.new() is deprecated and will be removed. Please use cloudinary.videoPlayer() instead.' + ); + return { + videoPlayer, + videoPlayers + }; +}; + +export default setupCloudinaryGlobal({ + videoPlayer, + videoPlayers, + player, + players, + Cloudinary: { + new: cloudinaryVideoPlayerLegacyConfig + } +}); diff --git a/src/plugins/analytics/index.js b/src/plugins/analytics/index.js index 00c53b7e4..d2e73faeb 100644 --- a/src/plugins/analytics/index.js +++ b/src/plugins/analytics/index.js @@ -1,5 +1,5 @@ import videojs from 'video.js'; -import { normalizeEventsParam } from 'extended-events'; +import { normalizeEventsParam } from '../../extended-events'; import { PLAYER_EVENT } from '../../utils/consts'; const DEFAULT_EVENTS = [ diff --git a/src/plugins/autoplay-on-scroll/index.js b/src/plugins/autoplay-on-scroll/index.js index 52687241e..5e332b00c 100644 --- a/src/plugins/autoplay-on-scroll/index.js +++ b/src/plugins/autoplay-on-scroll/index.js @@ -1,5 +1,5 @@ -import { isElementInViewport } from 'utils/positioning'; -import { sliceProperties } from 'utils/slicing'; +import { isElementInViewport } from '~/utils/positioning'; +import { sliceProperties } from '~/utils/slicing'; const defaults = { fraction: 0.5, diff --git a/src/plugins/cloudinary/common.js b/src/plugins/cloudinary/common.js index 2e23a69fa..d37ebfd50 100644 --- a/src/plugins/cloudinary/common.js +++ b/src/plugins/cloudinary/common.js @@ -1,12 +1,11 @@ import videojs from 'video.js'; import omit from 'lodash/omit'; -import { sliceAndUnsetProperties } from 'utils/slicing'; +import { sliceAndUnsetProperties } from '~/utils/slicing'; import isObject from 'lodash/isObject'; import isString from 'lodash/isString'; -import { URL_PATTERN } from './models/video-source/video-source.const'; import { createCloudinaryLegacyURL } from '@cloudinary/url-gen/backwards/createCloudinaryLegacyURL'; import Transformation from '@cloudinary/url-gen/backwards/transformation'; -import { unsigned_url_prefix } from '@cloudinary/url-gen/backwards/utils/unsigned_url_prefix'; +export { isRawUrl, getCloudinaryUrlPrefix } from './url-helpers'; const normalizeOptions = (publicId, options, { tolerateMissingId = false } = {}) => { if (isObject(publicId)) { @@ -26,8 +25,6 @@ const normalizeOptions = (publicId, options, { tolerateMissingId = false } = {}) return { publicId, options }; }; -export const isRawUrl = publicId => URL_PATTERN.test(publicId); - const isSrcEqual = (source1, source2) => { let src1 = source1; let src2 = source2; @@ -56,19 +53,6 @@ export const extendCloudinaryConfig = (currentConfig, newConfig) => export const getCloudinaryUrl = (publicId, transformation) => createCloudinaryLegacyURL(publicId, omit(transformation, ['chainTarget'])); -export const getCloudinaryUrlPrefix = (cloudinaryConfig) => { - return unsigned_url_prefix( - null, - cloudinaryConfig.cloud_name, - cloudinaryConfig.private_cdn, - cloudinaryConfig.cdn_subdomain, - cloudinaryConfig.secure_cdn_subdomain, - cloudinaryConfig.cname, - cloudinaryConfig.secure ?? true, - cloudinaryConfig.secure_distribution, - ); -}; - const isTransformationInstance = transformation => transformation.constructor.name === 'Transformation' && transformation.toOptions; diff --git a/src/plugins/cloudinary/index.js b/src/plugins/cloudinary/index.js index 4ba965148..1582f7527 100644 --- a/src/plugins/cloudinary/index.js +++ b/src/plugins/cloudinary/index.js @@ -1,8 +1,8 @@ import isFunction from 'lodash/isFunction'; import isEmpty from 'lodash/isEmpty'; -import { applyWithProps } from 'utils/apply-with-props'; -import { sliceAndUnsetProperties } from 'utils/slicing'; -import { isKeyInTransformation } from 'utils/cloudinary'; +import { applyWithProps } from '~/utils/apply-with-props'; +import { sliceAndUnsetProperties } from '~/utils/slicing'; +import { isKeyInTransformation } from '~/utils/cloudinary'; import { normalizeOptions, mergeTransformations, @@ -11,13 +11,13 @@ import { setupCloudinaryMiddleware, isRawUrl } from './common'; -import { CROP_MODE } from 'video-player.const'; +import { CROP_MODE } from '~/video-player.const'; import VideoSource from './models/video-source/video-source'; import EventHandlerRegistry from './event-handler-registry'; import AudioSource from './models/audio-source/audio-source'; import { DEFAULT_DPR, RENDITIONS } from './models/video-source/video-source.const'; -import recommendationsOverlay from 'components/recommendations-overlay'; +import recommendationsOverlay from '~/components/recommendations-overlay'; export const getEffectiveDpr = (maxDpr) => { const deviceDpr = typeof window !== 'undefined' && window.devicePixelRatio != null diff --git a/src/plugins/cloudinary/models/audio-source/audio-source.js b/src/plugins/cloudinary/models/audio-source/audio-source.js index 5a4e03f9a..c107a3744 100644 --- a/src/plugins/cloudinary/models/audio-source/audio-source.js +++ b/src/plugins/cloudinary/models/audio-source/audio-source.js @@ -1,8 +1,8 @@ import VideoSource from '../video-source/video-source'; import ImageSource from '../image-source'; import { normalizeOptions } from '../../common'; -import { sliceAndUnsetProperties } from 'utils/slicing'; -import { appendQueryParams } from 'utils/querystring'; +import { sliceAndUnsetProperties } from '~/utils/slicing'; +import { appendQueryParams } from '~/utils/querystring'; import { AUDIO_SUFFIX_REMOVAL_PATTERN, DEFAULT_AUDIO_PARAMS, DEFAULT_POSTER_PARAMS } from './audio-source.const'; import { SOURCE_TYPE } from '../../../../utils/consts'; diff --git a/src/plugins/cloudinary/models/base-source.js b/src/plugins/cloudinary/models/base-source.js index 82bed7142..4f31ce943 100644 --- a/src/plugins/cloudinary/models/base-source.js +++ b/src/plugins/cloudinary/models/base-source.js @@ -1,6 +1,6 @@ import { getCloudinaryUrl, isRawUrl, mergeTransformations, normalizeOptions } from '../common'; -import { sliceAndUnsetProperties } from 'utils/slicing'; -import { appendQueryParams } from 'utils/querystring'; +import { sliceAndUnsetProperties } from '~/utils/slicing'; +import { appendQueryParams } from '~/utils/querystring'; class BaseSource { diff --git a/src/plugins/cloudinary/models/video-source/video-source.js b/src/plugins/cloudinary/models/video-source/video-source.js index 0436ab5da..47dc51c05 100644 --- a/src/plugins/cloudinary/models/video-source/video-source.js +++ b/src/plugins/cloudinary/models/video-source/video-source.js @@ -1,7 +1,7 @@ -import { appendQueryParams } from 'utils/querystring'; +import { appendQueryParams } from '~/utils/querystring'; import castArray from 'lodash/castArray'; -import { SOURCE_TYPE } from 'utils/consts'; -import { SOURCE_PARAMS } from 'video-player.const'; +import { SOURCE_TYPE } from '~/utils/consts'; +import { SOURCE_PARAMS } from '../../../../video-player.const'; import { CONTAINER_MIME_TYPES, ADAPTIVE_SOURCETYPES, @@ -15,7 +15,7 @@ import { normalizeFormat } from './video-source.utils'; import { normalizeOptions, isSrcEqual, isRawUrl, mergeTransformations, getCloudinaryUrlPrefix } from '../../common'; -import { utf8ToBase64 } from 'utils/utf8Base64'; +import { utf8ToBase64 } from '~/utils/utf8Base64'; import Transformation from '@cloudinary/url-gen/backwards/transformation'; import BaseSource from '../base-source'; import ImageSource from '../image-source'; diff --git a/src/plugins/cloudinary/models/video-source/video-source.utils.js b/src/plugins/cloudinary/models/video-source/video-source.utils.js index f1aca04be..ab8abd87f 100644 --- a/src/plugins/cloudinary/models/video-source/video-source.utils.js +++ b/src/plugins/cloudinary/models/video-source/video-source.utils.js @@ -1,7 +1,7 @@ import { CONTAINER_MIME_TYPES, FORMAT_MAPPINGS } from './video-source.const'; import { VIDEO_CODEC } from '../../common'; import isString from 'lodash/isString'; -import { isKeyInTransformation } from 'utils/cloudinary'; +import { isKeyInTransformation } from '~/utils/cloudinary'; export function formatToMimeTypeAndTransformation(format) { const [container, codec] = format.toLowerCase().split('/'); diff --git a/src/plugins/cloudinary/url-helpers.js b/src/plugins/cloudinary/url-helpers.js new file mode 100644 index 000000000..c88bab322 --- /dev/null +++ b/src/plugins/cloudinary/url-helpers.js @@ -0,0 +1,5 @@ +import { URL_PATTERN } from './models/video-source/video-source.const'; + +export { getCloudinaryUrlPrefix } from '../../utils/cloudinary-url-prefix'; + +export const isRawUrl = publicId => URL_PATTERN.test(publicId); diff --git a/src/plugins/context-menu/components/context-menu-item.js b/src/plugins/context-menu/components/context-menu-item.js index b4b48e906..5050a6895 100644 --- a/src/plugins/context-menu/components/context-menu-item.js +++ b/src/plugins/context-menu/components/context-menu-item.js @@ -1,5 +1,5 @@ import videojs from 'video.js'; -import { createElement } from 'utils/dom'; +import { createElement } from '~/utils/dom'; const MenuItem = videojs.getComponent('MenuItem'); diff --git a/src/plugins/context-menu/components/context-menu.js b/src/plugins/context-menu/components/context-menu.js index d1ebea375..9fb76e13d 100644 --- a/src/plugins/context-menu/components/context-menu.js +++ b/src/plugins/context-menu/components/context-menu.js @@ -1,7 +1,7 @@ import videojs from 'video.js'; import isFunction from 'lodash/isFunction'; import ContextMenuItem from './context-menu-item'; -import { setPosition } from 'utils/positioning'; +import { setPosition } from '~/utils/positioning'; const Menu = videojs.getComponent('Menu'); diff --git a/src/plugins/context-menu/index.js b/src/plugins/context-menu/index.js index e8dd07570..fab541d73 100644 --- a/src/plugins/context-menu/index.js +++ b/src/plugins/context-menu/index.js @@ -1,8 +1,8 @@ import videojs from 'video.js'; import isFunction from 'lodash/isFunction'; import ContextMenu from './components/context-menu'; -import { getPointerPosition } from 'utils/positioning'; -import { sliceProperties } from 'utils/slicing'; +import { getPointerPosition } from '~/utils/positioning'; +import { sliceProperties } from '~/utils/slicing'; import './videojs-contextmenu'; import './context-menu.scss'; diff --git a/src/plugins/floating-player/index.js b/src/plugins/floating-player/index.js index 7c0e9e41f..6664e535c 100644 --- a/src/plugins/floating-player/index.js +++ b/src/plugins/floating-player/index.js @@ -1,5 +1,5 @@ -import { isElementInViewport } from 'utils/positioning'; -import { sliceProperties } from 'utils/slicing'; +import { isElementInViewport } from '~/utils/positioning'; +import { sliceProperties } from '~/utils/slicing'; import './floating-player.scss'; import { FLOATING_TO } from '../../video-player.const'; diff --git a/src/plugins/ima/index.js b/src/plugins/ima/index.js index 71b241284..d412ec600 100644 --- a/src/plugins/ima/index.js +++ b/src/plugins/ima/index.js @@ -1,6 +1,6 @@ /* global google */ import isFunction from 'lodash/isFunction'; -import { PLAYER_EVENT } from 'utils/consts'; +import { PLAYER_EVENT } from '~/utils/consts'; export default async function imaPlugin(player, playerOptions) { await import(/* webpackChunkName: "ima" */ './ima'); diff --git a/src/plugins/playlist/playlist.js b/src/plugins/playlist/playlist.js index 5b79a7703..c64a262c7 100644 --- a/src/plugins/playlist/playlist.js +++ b/src/plugins/playlist/playlist.js @@ -1,6 +1,6 @@ -import { sliceProperties } from 'utils/slicing'; -import { PLAYER_EVENT } from 'utils/consts'; -import { getCloudinaryUrl } from 'plugins/cloudinary/common'; +import { sliceProperties } from '~/utils/slicing'; +import { PLAYER_EVENT } from '~/utils/consts'; +import { getCloudinaryUrl } from '~/plugins/cloudinary/common'; import { normalizeJsonResponse } from './utils/api'; import Playlist from './ui/playlist'; diff --git a/src/plugins/playlist/ui/components/upcoming-video-overlay.js b/src/plugins/playlist/ui/components/upcoming-video-overlay.js index 841aff8c1..69b997b6f 100644 --- a/src/plugins/playlist/ui/components/upcoming-video-overlay.js +++ b/src/plugins/playlist/ui/components/upcoming-video-overlay.js @@ -1,6 +1,6 @@ import videojs from 'video.js'; import './upcoming-video-overlay.scss'; -import { PLAYER_EVENT } from 'utils/consts'; +import { PLAYER_EVENT } from '~/utils/consts'; // support VJS5 & VJS6 at the same time const dom = videojs.dom || videojs; diff --git a/src/plugins/playlist/ui/layout/playlist-layout.js b/src/plugins/playlist/ui/layout/playlist-layout.js index 57d94f403..4192ca0f5 100644 --- a/src/plugins/playlist/ui/layout/playlist-layout.js +++ b/src/plugins/playlist/ui/layout/playlist-layout.js @@ -1,10 +1,10 @@ import videojs from 'video.js'; -import { PLAYER_EVENT } from 'utils/consts'; +import { PLAYER_EVENT } from '~/utils/consts'; import { wrap } from '../../utils/dom'; import { skinClassPrefix, playerClassPrefix -} from 'utils/css-prefix'; +} from '~/utils/css-prefix'; const dom = videojs.dom || videojs; const Component = videojs.getComponent('Component'); diff --git a/src/plugins/playlist/ui/panel/playlist-panel.js b/src/plugins/playlist/ui/panel/playlist-panel.js index 3bc474983..c4c5a95bf 100644 --- a/src/plugins/playlist/ui/panel/playlist-panel.js +++ b/src/plugins/playlist/ui/panel/playlist-panel.js @@ -1,6 +1,6 @@ import videojs from 'video.js'; import PlaylistPanelItem from './playlist-panel-item'; -import { PLAYER_EVENT } from 'utils/consts'; +import { PLAYER_EVENT } from '~/utils/consts'; const Component = videojs.getComponent('Component'); diff --git a/src/plugins/playlist/ui/playlist-widget.js b/src/plugins/playlist/ui/playlist-widget.js index 8c3a4ff24..02a2a109c 100644 --- a/src/plugins/playlist/ui/playlist-widget.js +++ b/src/plugins/playlist/ui/playlist-widget.js @@ -1,5 +1,5 @@ import videojs from 'video.js'; -import { PLAYER_EVENT } from 'utils/consts'; +import { PLAYER_EVENT } from '~/utils/consts'; import PlaylistLayoutHorizontal from './layout/playlist-layout-horizontal'; import PlaylistLayoutVertical from './layout/playlist-layout-vertical'; import PlaylistLayoutCustom from './layout/playlist-layout-custom'; diff --git a/src/plugins/playlist/ui/playlist.js b/src/plugins/playlist/ui/playlist.js index d48aa6b84..bf87f8682 100644 --- a/src/plugins/playlist/ui/playlist.js +++ b/src/plugins/playlist/ui/playlist.js @@ -1,4 +1,4 @@ -import VideoSource from 'plugins/cloudinary/models/video-source/video-source'; +import VideoSource from '~/plugins/cloudinary/models/video-source/video-source'; import isInteger from 'lodash/isInteger'; import './components/upcoming-video-overlay'; diff --git a/src/plugins/share/share.js b/src/plugins/share/share.js index 35483f6fd..1f1c0d1dc 100644 --- a/src/plugins/share/share.js +++ b/src/plugins/share/share.js @@ -1,6 +1,6 @@ import './components/download-button'; import './share.scss'; -import { getCloudinaryUrl } from 'plugins/cloudinary/common'; +import { getCloudinaryUrl } from '~/plugins/cloudinary/common'; import omit from 'lodash/omit'; const SharePlugin = function (options = {}, playerInstance) { diff --git a/src/plugins/styled-text-tracks/styled-text-tracks.js b/src/plugins/styled-text-tracks/styled-text-tracks.js index f016b7a3a..c168221fb 100644 --- a/src/plugins/styled-text-tracks/styled-text-tracks.js +++ b/src/plugins/styled-text-tracks/styled-text-tracks.js @@ -1,5 +1,5 @@ -import { fontFace } from 'utils/fontFace'; -import { playerClassPrefix } from 'utils/css-prefix'; +import { fontFace } from '~/utils/fontFace'; +import { playerClassPrefix } from '~/utils/css-prefix'; import './styled-text-tracks.scss'; diff --git a/src/utils/cloudinary-url-prefix.js b/src/utils/cloudinary-url-prefix.js new file mode 100644 index 000000000..c052422f5 --- /dev/null +++ b/src/utils/cloudinary-url-prefix.js @@ -0,0 +1,14 @@ +import { unsigned_url_prefix } from '@cloudinary/url-gen/backwards/utils/unsigned_url_prefix'; + +export const getCloudinaryUrlPrefix = (cloudinaryConfig) => { + return unsigned_url_prefix( + null, + cloudinaryConfig.cloud_name, + cloudinaryConfig.private_cdn, + cloudinaryConfig.cdn_subdomain, + cloudinaryConfig.secure_cdn_subdomain, + cloudinaryConfig.cname, + cloudinaryConfig.secure ?? true, + cloudinaryConfig.secure_distribution, + ); +}; diff --git a/src/utils/cloudinary.js b/src/utils/cloudinary.js index 3cf6dfd5a..a40db1f04 100644 --- a/src/utils/cloudinary.js +++ b/src/utils/cloudinary.js @@ -1,5 +1,5 @@ import { cloudinaryErrorsConverter, ERROR_CODE } from '../plugins/cloudinary/common'; -import { find } from 'utils/find'; +import { find } from '~/utils/find'; export const GET_ERROR_DEFAULT_REQUEST = { method: 'head' }; const ERROR_WITH_GET_REQUEST = { method: 'get', credentials: 'include', headers: { 'Content-Range': 'bytes=0-0' } }; diff --git a/src/utils/fetch-config.js b/src/utils/fetch-config.js index eb8769763..e34fef38b 100644 --- a/src/utils/fetch-config.js +++ b/src/utils/fetch-config.js @@ -1,5 +1,5 @@ import { defaultProfiles } from 'cloudinary-video-player-profiles'; -import { isRawUrl, getCloudinaryUrlPrefix } from '../plugins/cloudinary/common'; +import { isRawUrl, getCloudinaryUrlPrefix } from '../plugins/cloudinary/url-helpers'; import { utf8ToBase64 } from '../utils/utf8Base64'; import { appendQueryParams } from './querystring'; diff --git a/src/utils/get-analytics-player-options.js b/src/utils/get-analytics-player-options.js index 0a9e12061..d180ebabd 100644 --- a/src/utils/get-analytics-player-options.js +++ b/src/utils/get-analytics-player-options.js @@ -1,4 +1,4 @@ -import defaults from 'config/defaults'; +import defaults from '~/config/defaults'; import isEmpty from 'lodash/isEmpty'; const hasConfig = (obj) => isEmpty(obj) ? null : true; @@ -129,6 +129,7 @@ export const getAnalyticsFromPlayerOptions = (playerOptions) => filterDefaultsAn withCredentials: playerOptions.withCredentials, debug: playerOptions.debug, type: playerOptions.type, + schedule: hasConfig(playerOptions.schedule?.weekly), colors: hasConfig(playerOptions.colors), controlBar: hasConfig(playerOptions.controlBar), diff --git a/src/utils/player-api.js b/src/utils/player-api.js new file mode 100644 index 000000000..6113aa991 --- /dev/null +++ b/src/utils/player-api.js @@ -0,0 +1,41 @@ +import { scheduleBootstrap, shouldUseScheduleBootstrap, getElementForSchedule } from './schedule'; + +export const createAsyncPlayer = async (id, playerOptions, ready, createFn) => { + const mergedOptions = Object.assign({}, playerOptions); + const videoElement = getElementForSchedule(id); + + const opts = await (async () => { + try { + const { fetchAndMergeConfig } = await import('./fetch-config'); + const fetched = await fetchAndMergeConfig(mergedOptions); + return Object.assign({}, fetched, mergedOptions); + } catch { + return mergedOptions; + } + })(); + + if (shouldUseScheduleBootstrap(opts)) { + return scheduleBootstrap(id, opts); + } + + return createFn(videoElement, opts, ready); +}; + +export const createMultiplePlayers = async (selector, playerOptions, ready, playerFn) => { + const nodeList = document.querySelectorAll(selector); + return Promise.all([...nodeList].map((node) => playerFn(node, playerOptions, ready))); +}; + +export const createMultipleSync = (selector, playerOptions, ready, playerFn) => { + const nodeList = document.querySelectorAll(selector); + return [...nodeList].map((node) => playerFn(node, playerOptions, ready)); +}; + +export const setupCloudinaryGlobal = (methods) => { + const cloudinary = { + ...(window.cloudinary || {}), + ...methods, + }; + window.cloudinary = cloudinary; + return cloudinary; +}; diff --git a/src/utils/poster-url.js b/src/utils/poster-url.js new file mode 100644 index 000000000..765c3e546 --- /dev/null +++ b/src/utils/poster-url.js @@ -0,0 +1,25 @@ +/** + * Minimal Cloudinary poster URL builder for video first frame. + * Used by schedule bootstrap when outside schedule (no full player loaded). + */ +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 + */ +export const buildPosterUrl = (cloudName, publicId, cloudinaryConfig = {}) => { + const config = { + cloud_name: cloudName || cloudinaryConfig.cloud_name, + ...cloudinaryConfig, + secure: cloudinaryConfig.secure ?? true + }; + + const prefix = getCloudinaryUrlPrefix(config); + return `${prefix}/video/upload/${POSTER_TRANSFORMATION}/${publicId}`; +}; diff --git a/src/utils/schedule.js b/src/utils/schedule.js new file mode 100644 index 000000000..69184ee4d --- /dev/null +++ b/src/utils/schedule.js @@ -0,0 +1,205 @@ +/** + * Schedule utilities: weekly time-range parsing and bootstrap (poster rendering). + * Uses browser local time. No videojs dependency for the bootstrap path. + */ +import cssEscape from 'css.escape'; +import { buildPosterUrl } from './poster-url'; + +const INTERNAL_ANALYTICS_URL = 'https://analytics-api-s.cloudinary.com'; + +const sendScheduleImageAnalytics = (options) => { + const allowReport = options?.sourceOptions?.allowUsageReport ?? options?.allowUsageReport; + if (allowReport === false) return; + try { + const params = new URLSearchParams({ + scheduleImageRendered: 'true', + cloudName: options?.cloudName || options?.cloudinaryConfig?.cloud_name || '', + }).toString(); + fetch(`${INTERNAL_ANALYTICS_URL}/video_player_source?${params}`); + } catch { + // noop + } +}; + +const getCloudNameFromOptions = (options) => + options?.cloudName || options?.cloud_name || options?.cloudinaryConfig?.cloud_name; + +const getPublicIdFromOptions = (options) => + options?.publicId || options?.sourceOptions?.publicId; + +/** + * Returns true when schedule.weekly is configured and current time is outside the schedule. + * @param {object} options - player options + * @returns {boolean} + */ +export const shouldUseScheduleBootstrap = (options) => { + const schedule = options?.schedule; + const weekly = schedule?.weekly; + return Array.isArray(weekly) && weekly.length > 0 && !isWithinSchedule(schedule, new Date()); +}; + +/** + * 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 + * @returns {object} Stub with source() and loadPlayer() + */ +export const scheduleBootstrap = (elem, options) => { + const videoElement = getElementForSchedule(elem); + const cloudName = getCloudNameFromOptions(options); + const publicId = getPublicIdFromOptions(options); + + if (!cloudName || !publicId) { + throw new Error('schedule.weekly requires cloudName and publicId when outside schedule'); + } + + 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, { + fluid, + width: options?.width, + height: options?.height, + cropMode: options?.sourceOptions?.cropMode, + }); + + sendScheduleImageAnalytics(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)); + } + }; + + return stub; +}; + +const DAY_MAP = { + sunday: 0, sun: 0, + monday: 1, mon: 1, + tuesday: 2, tue: 2, tues: 2, + wednesday: 3, wed: 3, + thursday: 4, thu: 4, thur: 4, thurs: 4, + friday: 5, fri: 5, + 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) + * @returns {number|null} 0-6, or null if invalid + */ +export const parseDay = (day) => { + if (typeof day !== 'string') return null; + const key = day.toLowerCase().trim(); + return DAY_MAP[key] ?? null; +}; + +/** + * Parse "HH:mm" string to minutes since midnight. + * @param {string} timeStr - "09:00" or "17:30" + * @returns {number|null} minutes, or null if invalid + */ +const parseTime = (timeStr) => { + if (typeof timeStr !== 'string') return null; + const match = timeStr.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + const h = parseInt(match[1], 10); + const m = parseInt(match[2], 10); + if (h < 0 || h > 23 || m < 0 || m > 59) return null; + return h * 60 + m; +}; + +/** + * Check if a date falls within any configured weekly slot (local time). + * @param {{ weekly?: Array<{ day: string, start: string, duration: number }> }} schedule - schedule config + * @param {Date} date - date to check (uses local time) + * @returns {boolean} true if within a slot + */ +export const isWithinSchedule = (schedule, date) => { + const weekly = schedule?.weekly; + if (!Array.isArray(weekly) || weekly.length === 0) return true; + + const WEEK = 7 * 1440; + const nowInWeek = date.getDay() * 1440 + date.getHours() * 60 + date.getMinutes(); + + for (const slot of weekly) { + const slotDay = parseDay(slot.day); + if (slotDay === null) continue; + + const startMin = parseTime(slot.start); + if (startMin === null || typeof slot.duration !== 'number' || slot.duration <= 0) continue; + + const slotStart = slotDay * 1440 + startMin; + const durationMin = slot.duration * 60; + const elapsed = (nowInWeek - slotStart + WEEK) % WEEK; + + if (elapsed < durationMin) return true; + } + + 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 }; +}; diff --git a/src/validators/validators.js b/src/validators/validators.js index 52102546f..2ed10b946 100644 --- a/src/validators/validators.js +++ b/src/validators/validators.js @@ -70,6 +70,13 @@ export const playerValidators = { postrollTimeout: validator.isNumber, adsInPlaylist: validator.isString(ADS_IN_PLAYLIST) }, + schedule: { + weekly: validator.isArrayOfObjects({ + day: validator.isString, + start: validator.isString, + duration: validator.isNumber + }) + }, cloudinary: { autoShowRecommendations: validator.isBoolean, sourceTypes: validator.isArrayOfStrings, diff --git a/src/video-player.const.js b/src/video-player.const.js index e1dcaeac9..f5b7b9a92 100644 --- a/src/video-player.const.js +++ b/src/video-player.const.js @@ -61,6 +61,7 @@ export const PLAYER_PARAMS = SOURCE_PARAMS.concat([ 'seekThumbnails', 'showJumpControls', 'videoConfig', + 'schedule', ]); // We support both camelCase and snake_case for cloudinary SDK params diff --git a/src/video-player.js b/src/video-player.js index 9d673dcdb..01620efde 100644 --- a/src/video-player.js +++ b/src/video-player.js @@ -15,7 +15,9 @@ import ExtendedEvents from './extended-events'; import VideoSource from './plugins/cloudinary/models/video-source/video-source'; import { overrideDefaultVideojsComponents, - splitOptions + splitOptions, + getResolveVideoElement, + extractOptions } from './video-player.utils'; import { FLOATING_TO, FLUID_CLASS_NAME } from './video-player.const'; import { isValidPlayerConfig, isValidSourceConfig } from './validators/validators-functions'; @@ -903,4 +905,29 @@ class VideoPlayer { } } +const mergeDefaults = (options) => videojs.obj.merge({}, defaults, options); + +const getConfig = (elem, playerOptions = {}) => { + const videoElement = getResolveVideoElement(elem); + const options = extractOptions(videoElement, playerOptions); + return { videoElement, options }; +}; + +export const createVideoPlayer = (elem, playerOptions = {}, ready) => { + const { videoElement, options } = getConfig(elem, playerOptions); + if (options.profile) { + console.warn('Profile option requires async initialization. Use cloudinary.player() instead of cloudinary.videoPlayer()'); + } + return new VideoPlayer(videoElement, mergeDefaults(options), ready); +}; + +/** + * Create player with pre-merged config (skips fetch). + * Used by player() when config was already fetched for schedule check. + */ +export const createPlayerWithConfig = (elem, mergedOptions, ready) => { + const { videoElement, options } = getConfig(elem, mergedOptions); + return new VideoPlayer(videoElement, mergeDefaults(options), ready); +}; + export default VideoPlayer; diff --git a/test/e2e/specs/ESM/esmEntryPointsPage.spec.ts b/test/e2e/specs/ESM/esmEntryPointsPage.spec.ts new file mode 100644 index 000000000..2bcd8a72e --- /dev/null +++ b/test/e2e/specs/ESM/esmEntryPointsPage.spec.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; +import { vpTest } from '../../fixtures/vpTest'; +import { ESM_URL } from '../../testData/esmUrl'; +import { ExampleLinkName } from '../../testData/ExampleLinkNames'; +import { getEsmLinkByName } from '../../testData/esmPageLinksData'; +import { waitForPageToLoadWithTimeout } from '../../src/helpers/waitForPageToLoadWithTimeout'; + +const link = getEsmLinkByName(ExampleLinkName.EntryPoints); + +vpTest('Given entry-points page, when loaded, then all exports resolve without errors', async ({ page, pomPages, consoleErrors }) => { + await page.goto(ESM_URL); + await pomPages.mainPage.clickLinkByName(link.name); + await waitForPageToLoadWithTimeout(page, 10000); + + const results = await page.locator('#results').textContent(); + expect(results).toContain('Player created successfully'); + expect(results).not.toContain('Error'); + + const failCount = (results.match(/❌/g) || []).length; + expect(failCount, `${failCount} checks failed in entry-points results`).toBe(0); +}); diff --git a/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts new file mode 100644 index 000000000..bc7da06a3 --- /dev/null +++ b/test/e2e/specs/ESM/esmLazyLoadingPage.spec.ts @@ -0,0 +1,24 @@ +import { expect } from '@playwright/test'; +import { vpTest } from '../../fixtures/vpTest'; +import { ESM_URL } from '../../testData/esmUrl'; +import { ExampleLinkName } from '../../testData/ExampleLinkNames'; +import { getEsmLinkByName } from '../../testData/esmPageLinksData'; +import { waitForPageToLoadWithTimeout } from '../../src/helpers/waitForPageToLoadWithTimeout'; + +const link = getEsmLinkByName(ExampleLinkName.LazyLoading); + +vpTest('Given lazy-loading 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'); + + await page.locator('#load-btn').click(); + await waitForPageToLoadWithTimeout(page, 10000); + + const updatedResults = await page.locator('#results').textContent(); + expect(updatedResults).toContain('Player loaded and created'); +}); diff --git a/test/e2e/specs/ESM/linksConsoleErrorsEsmPage.spec.ts b/test/e2e/specs/ESM/linksConsoleErrorsEsmPage.spec.ts index dc83b2758..f840aed8e 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 = 38; + const expectedNumberOfLinks = 43; 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 7df30862c..206fadd32 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 = 42; + const expectedNumberOfLinks = 43; 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 7283d21a4..29c8117ce 100644 --- a/test/e2e/testData/ExampleLinkNames.ts +++ b/test/e2e/testData/ExampleLinkNames.ts @@ -30,6 +30,7 @@ export enum ExampleLinkName { Profiles = 'Profiles', RawURL = 'Raw URL', Recommendations = 'Recommendations', + Schedule = 'Schedule (weekly time slots)', SeekThumbnails = 'Seek Thumbnails', ShoppableVideos = 'Shoppable Videos', SubtitlesAndCaptions = 'Subtitles & Captions', @@ -43,4 +44,6 @@ export enum ExampleLinkName { SourceSwitcher = 'Source switcher', VisualSearch = 'Visual Search', VideoDetails = 'Video Details', + EntryPoints = 'Entry Points (all exports)', + LazyLoading = 'Lazy Loading', } diff --git a/test/e2e/testData/esmPageLinksData.ts b/test/e2e/testData/esmPageLinksData.ts index 656abfff1..aebfbec3f 100644 --- a/test/e2e/testData/esmPageLinksData.ts +++ b/test/e2e/testData/esmPageLinksData.ts @@ -33,6 +33,7 @@ export const ESM_LINKS: ExampleLinkType[] = [ { name: ExampleLinkName.Profiles, endpoint: 'profiles' }, { name: ExampleLinkName.RawURL, endpoint: 'raw-url' }, { name: ExampleLinkName.Recommendations, endpoint: 'recommendations' }, + { name: ExampleLinkName.Schedule, endpoint: 'schedule' }, { name: ExampleLinkName.SeekThumbnails, endpoint: 'seek-thumbs' }, { name: ExampleLinkName.ShareAndDownload, endpoint: 'share-plugin' }, { name: ExampleLinkName.ShoppableVideos, endpoint: 'shoppable' }, @@ -42,6 +43,8 @@ export const ESM_LINKS: ExampleLinkType[] = [ { name: ExampleLinkName.VR360Videos, endpoint: '360' }, { name: ExampleLinkName.AllBuild, endpoint: 'all' }, { name: ExampleLinkName.VideoDetails, endpoint: 'video-details' }, + { name: ExampleLinkName.EntryPoints, endpoint: 'entry-points' }, + { name: ExampleLinkName.LazyLoading, endpoint: 'lazy-loading' }, ]; /** diff --git a/test/e2e/testData/pageLinksData.ts b/test/e2e/testData/pageLinksData.ts index a4d6e505e..93ec5d9b3 100644 --- a/test/e2e/testData/pageLinksData.ts +++ b/test/e2e/testData/pageLinksData.ts @@ -33,6 +33,7 @@ export const LINKS: ExampleLinkType[] = [ { name: ExampleLinkName.Profiles, endpoint: 'profiles.html' }, { name: ExampleLinkName.RawURL, endpoint: 'raw-url.html' }, { name: ExampleLinkName.Recommendations, endpoint: 'recommendations.html' }, + { name: ExampleLinkName.Schedule, endpoint: 'schedule.html' }, { name: ExampleLinkName.SeekThumbnails, endpoint: 'seek-thumbs.html' }, { name: ExampleLinkName.ShareAndDownload, endpoint: 'share-plugin.html' }, { name: ExampleLinkName.ShoppableVideos, endpoint: 'shoppable.html' }, diff --git a/test/unit/poster-url.test.js b/test/unit/poster-url.test.js new file mode 100644 index 000000000..bcfd7643c --- /dev/null +++ b/test/unit/poster-url.test.js @@ -0,0 +1,24 @@ +import { buildPosterUrl } 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('/video/upload/'); + expect(url).toContain('so_0'); + expect(url).toContain('f_auto'); + expect(url).toContain('q_auto'); + }); + + it('uses https by default', () => { + const url = buildPosterUrl('demo', 'sample'); + expect(url).toMatch(/^https:\/\//); + }); + + it('accepts cloudinaryConfig for overrides', () => { + const url = buildPosterUrl('demo', 'sample', { secure: true }); + expect(url).toContain('sample'); + }); +}); diff --git a/test/unit/schedule-bootstrap.test.js b/test/unit/schedule-bootstrap.test.js new file mode 100644 index 000000000..cbb77da8b --- /dev/null +++ b/test/unit/schedule-bootstrap.test.js @@ -0,0 +1,68 @@ +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/schedule.test.js b/test/unit/schedule.test.js new file mode 100644 index 000000000..474e247c2 --- /dev/null +++ b/test/unit/schedule.test.js @@ -0,0 +1,109 @@ +import { parseDay, isWithinSchedule } from '../../src/utils/schedule'; + +describe('schedule utils', () => { + describe('parseDay', () => { + it('parses full day names (case-insensitive)', () => { + expect(parseDay('sunday')).toBe(0); + expect(parseDay('monday')).toBe(1); + expect(parseDay('tuesday')).toBe(2); + expect(parseDay('wednesday')).toBe(3); + expect(parseDay('thursday')).toBe(4); + expect(parseDay('friday')).toBe(5); + expect(parseDay('saturday')).toBe(6); + expect(parseDay('SUNDAY')).toBe(0); + expect(parseDay('Monday')).toBe(1); + }); + + it('parses abbreviated day names', () => { + expect(parseDay('sun')).toBe(0); + expect(parseDay('mon')).toBe(1); + expect(parseDay('tue')).toBe(2); + expect(parseDay('wed')).toBe(3); + expect(parseDay('thu')).toBe(4); + expect(parseDay('fri')).toBe(5); + expect(parseDay('sat')).toBe(6); + }); + + it('returns null for invalid input', () => { + expect(parseDay('invalid')).toBeNull(); + expect(parseDay('')).toBeNull(); + expect(parseDay(null)).toBeNull(); + expect(parseDay(123)).toBeNull(); + }); + + it('trims whitespace', () => { + expect(parseDay(' monday ')).toBe(1); + }); + }); + + describe('isWithinSchedule', () => { + it('returns true when weekly is empty or missing', () => { + expect(isWithinSchedule({}, new Date())).toBe(true); + expect(isWithinSchedule({ weekly: [] }, new Date())).toBe(true); + expect(isWithinSchedule({ weekly: null }, new Date())).toBe(true); + }); + + it('returns true when date falls within a slot', () => { + const monday9am = new Date(2025, 2, 10, 9, 30); + const schedule = { + weekly: [{ day: 'monday', start: '09:00', duration: 8 }] + }; + expect(isWithinSchedule(schedule, monday9am)).toBe(true); + }); + + it('returns false when date is outside all slots', () => { + const monday8am = new Date(2025, 2, 10, 8, 0); + const schedule = { + weekly: [{ day: 'monday', start: '09:00', duration: 8 }] + }; + expect(isWithinSchedule(schedule, monday8am)).toBe(false); + }); + + it('handles boundary times (start inclusive, end exclusive)', () => { + const monday9am = new Date(2025, 2, 10, 9, 0); + const monday5pm = new Date(2025, 2, 10, 17, 0); + const schedule = { + weekly: [{ day: 'monday', start: '09:00', duration: 8 }] + }; + expect(isWithinSchedule(schedule, monday9am)).toBe(true); + expect(isWithinSchedule(schedule, monday5pm)).toBe(false); + }); + + it('checks multiple slots', () => { + const wednesday2pm = new Date(2025, 2, 12, 14, 0); + const schedule = { + weekly: [ + { day: 'monday', start: '09:00', duration: 8 }, + { day: 'wednesday', start: '12:00', duration: 6 } + ] + }; + expect(isWithinSchedule(schedule, wednesday2pm)).toBe(true); + }); + + it('handles slot that crosses midnight into the next day', () => { + const schedule = { + weekly: [{ day: 'monday', start: '22:00', duration: 8 }] + }; + // Monday 23:00 - within slot on start day + const monday11pm = new Date(2025, 2, 10, 23, 0); + expect(isWithinSchedule(schedule, monday11pm)).toBe(true); + + // Tuesday 03:00 - within slot on next day + const tuesday3am = new Date(2025, 2, 11, 3, 0); + expect(isWithinSchedule(schedule, tuesday3am)).toBe(true); + + // Tuesday 07:00 - outside slot (ends at 06:00) + const tuesday7am = new Date(2025, 2, 11, 7, 0); + expect(isWithinSchedule(schedule, tuesday7am)).toBe(false); + }); + + it('handles cross-midnight slot wrapping Saturday to Sunday', () => { + const schedule = { + weekly: [{ day: 'saturday', start: '23:00', duration: 4 }] + }; + // Sunday 02:00 - within slot + const sunday2am = new Date(2025, 2, 9, 2, 0); + expect(isWithinSchedule(schedule, sunday2am)).toBe(true); + }); + }); +}); diff --git a/webpack/common.config.js b/webpack/common.config.js index 8d220ecd3..f91759439 100644 --- a/webpack/common.config.js +++ b/webpack/common.config.js @@ -12,7 +12,14 @@ const webpackConfig = { context: path.resolve(__dirname, '../src'), entry: { - 'cld-video-player': './index.js' + 'cld-video-player': { + import: './index.umd.js', + library: { name: 'cloudinary-video-player', type: 'umd' } + }, + 'cld-video-player-lazy': { + import: './index.lazy.js', + library: { name: 'cloudinary-video-player', type: 'umd' } + } }, output: { @@ -20,10 +27,6 @@ const webpackConfig = { chunkFilename: `[name]${minFilenamePart}.js`, path: path.resolve(__dirname, '../dist'), publicPath: 'auto', - library: { - name: 'cloudinary-video-player', - type: 'umd' - }, chunkLoadingGlobal: 'cloudinaryVideoPlayerChunkLoading' }, @@ -43,8 +46,9 @@ const webpackConfig = { resolve: { extensions: ['.js', '.scss'], - modules: [path.resolve(__dirname, '../src'), 'node_modules'], + modules: ['node_modules'], alias: { + '~': path.resolve(__dirname, '../src'), 'video.js': path.resolve(__dirname, '../node_modules/video.js/dist/alt/video.core.js'), 'video.root.js': path.resolve(__dirname, '../node_modules/video.js') } diff --git a/webpack/es6.config.js b/webpack/es6.config.js deleted file mode 100644 index e84e1c74b..000000000 --- a/webpack/es6.config.js +++ /dev/null @@ -1,43 +0,0 @@ -const { merge } = require('webpack-merge'); -const webpackCommon = require('./common.config'); -const path = require('path'); -const CopyWebpackPlugin = require('copy-webpack-plugin'); - -delete webpackCommon.output; // overwrite - -const outputPath = path.resolve(__dirname, '../lib'); - -module.exports = merge(webpackCommon, { - mode: 'production', - - entry: { - 'cld-video-player': './index.js', // default - 'videoPlayer': './index.videoPlayer.js', - 'player': './index.player.js', - 'all': './index.all.js' - }, - - output: { - filename: '[name].js', - path: outputPath, - chunkFilename: '[name].js', - publicPath: '', - library: { - type: 'module' - }, - chunkLoadingGlobal: 'cloudinaryVideoPlayerChunkLoading' - }, - - plugins: [ - new CopyWebpackPlugin({ - patterns: [{ - from: path.resolve(__dirname, '../src/config/configSchema.json'), - to: `${outputPath}/schema.json` - }] - }) - ], - - experiments: { - outputModule: true - } -});