diff --git a/docs/index.html b/docs/index.html index e5c9236b6..c97c293c1 100644 --- a/docs/index.html +++ b/docs/index.html @@ -76,6 +76,7 @@

Some code examples:

  • Seek Thumbnails
  • Share & Download
  • Shoppable Videos
  • +
  • Source switcher
  • Subtitles & Captions
  • Video Transformations
  • VAST & VPAID Support
  • diff --git a/docs/source-switcher.html b/docs/source-switcher.html new file mode 100644 index 000000000..a71fefaa5 --- /dev/null +++ b/docs/source-switcher.html @@ -0,0 +1,143 @@ + + + + + Cloudinary Video Player + + + + + + + + + + + + + + + + + + +
    + + +

    Cloudinary Video Player

    + +

    Source switcher

    + +
    + +
    + +

    + Full documentation +

    + +

    Example Code:

    + +
    +      
    +
    +      <video
    +        id="player-multiple-sources"
    +        controls
    +        autoplay
    +        class="cld-video-player"
    +        width="500">
    +      </video>
    +
    +      
    +    
    + +
    +      
    +      cloudinary.videoPlayer('player-multiple-sources', {
    +        cloudName: 'demo',
    +        videoSources: [
    +          {
    +            publicId: 'snow_horses'
    +          },
    +          {
    +            publicId: 'dirt_bike',
    +            textTracks: {
    +              captions: {
    +                label: 'Original',
    +                default: true,
    +              },
    +              subtitles: [
    +                {
    +                  label: 'English',
    +                  language: 'en-US',
    +                },
    +                {
    +                  label: 'Polish',
    +                  language: 'pl-PL',
    +                },
    +              ]
    +            },
    +          },
    +          {
    +            publicId: 'marmots',
    +            label: 'Custom video name',
    +            download: true,
    +          },
    +        ],
    +      });
    +      
    +    
    +
    + + + diff --git a/package.json b/package.json index b12290f46..d5ed60ff3 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ }, { "path": "./lib/all.js", - "maxSize": "320kb" + "maxSize": "325kb" } ] }, diff --git a/src/assets/icons/source_switcher_icon_for_black_bg.svg b/src/assets/icons/source_switcher_icon_for_black_bg.svg new file mode 100644 index 000000000..76b9ce71c --- /dev/null +++ b/src/assets/icons/source_switcher_icon_for_black_bg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/source_switcher_icon_for_white_bg.svg b/src/assets/icons/source_switcher_icon_for_white_bg.svg new file mode 100644 index 000000000..6e8e9adeb --- /dev/null +++ b/src/assets/icons/source_switcher_icon_for_white_bg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/index.js b/src/components/index.js index 8dd2f8fa7..3cc5d934b 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -3,11 +3,13 @@ import JumpBackButton from './jumpButtons/jump-10-minus'; import LogoButton from './logoButton/logo-button'; import ProgressControlEventsBlocker from './progress-control-events-blocker/progress-control-events-blocker'; import TitleBar from './title-bar/title-bar'; +import SourceSwitcherButton from './source-switcher-button/source-switcher-button'; export { JumpForwardButton, JumpBackButton, LogoButton, ProgressControlEventsBlocker, - TitleBar + TitleBar, + SourceSwitcherButton }; diff --git a/src/components/source-switcher-button/source-switcher-button.js b/src/components/source-switcher-button/source-switcher-button.js new file mode 100644 index 000000000..4c9caa1ac --- /dev/null +++ b/src/components/source-switcher-button/source-switcher-button.js @@ -0,0 +1,125 @@ +import videojs from 'video.js'; +import './source-switcher-button.scss'; + +const MenuButton = videojs.getComponent('MenuButton'); +const MenuItem = videojs.getComponent('MenuItem'); + +class SourceSwitcherButton extends MenuButton { + constructor(player, options = {}) { + super(player, options); + + this.controlText(options.tooltip || 'Sources'); + this._emptyLabel = options.emptyLabel || 'No sources'; + + this._items = options.items || []; + this._selectedIndex = Number.isInteger(options.defaultIndex) ? options.defaultIndex : undefined; + + const onSelected = typeof options.onSelected === 'function' ? options.onSelected : null; + + this._onSelected = onSelected || null; + this._setEnabled(Array.isArray(this._items) && this._items.length > 0); + } + + buildCSSClass() { + const empty = !Array.isArray(this._items) || this._items.length === 0; + return `vjs-source-switcher-button${empty ? ' vjs-source-switcher-disabled' : ''} ${super.buildCSSClass()}`; + } + + createItems() { + if (!Array.isArray(this._items) || this._items.length === 0) { + const empty = new MenuItem(this.player_, { + label: this._emptyLabel || 'No sources', + selectable: false + }); + empty.addClass('vjs-source-switcher-empty'); + empty.disable(); + return [empty]; + } + + return this._items.map(({ label, value }, index) => { + const item = new MenuItem(this.player_, { + label, + selectable: true, + selected: index === this._selectedIndex + }); + item.value = value; + item._ssIndex = index; + + item.on('click', () => { + this.menu.children().forEach((child) => { + if (child instanceof MenuItem) { + child.selected(child._ssIndex === index); + } + }); + + this._selectedIndex = index; + const payload = { index, value, label }; + if (this._onSelected) this._onSelected(payload, this.player_); + this.player_.trigger('sourceswitcher:change', payload); + }); + + return item; + }); + } + + setItems(items) { + this._items = items; + this._selectedIndex = this._items.length ? 0 : undefined; + + this._setEnabled(this._items.length > 0); + this._rebuildMenu(); + } + + setSelected(index) { + if ( + !Array.isArray(this._items) || + index == null || + index < 0 || + index >= this._items.length + ) return; + + this._selectedIndex = index; + + // reflect in UI if menu exists + if (this.menu) { + this.menu.children().forEach((child) => { + if (child instanceof MenuItem) { + child.selected(child._ssIndex === index); + } + }); + } + + const { label, value } = this._items[index]; + const payload = { index, value, label }; + if (this._onSelected) this._onSelected(payload, this.player_); + this.player_.trigger('sourceswitcher:change', payload); + } + + setOnSelected(fn) { + this._onSelected = typeof fn === 'function' ? fn : null; + } + + _rebuildMenu() { + if (!this.menu) return; + this.menu.children().slice().forEach((c) => this.menu.removeChild(c)); + this.createItems().forEach((i) => this.menu.addItem(i)); + + // Toggle disabled class based on emptiness + const el = this.el(); + if (el) { + const empty = this._items.length === 0; + el.classList.toggle('vjs-source-switcher-disabled', empty); + el.setAttribute('aria-disabled', String(empty)); + } + } + + _setEnabled(enabled) { + const el = this.el(); + if (!el) return; + el.classList.toggle('vjs-source-switcher-disabled', !enabled); + el.setAttribute('aria-disabled', String(!enabled)); + } +} + +videojs.registerComponent('sourceSwitcherButton', SourceSwitcherButton); +export default SourceSwitcherButton; diff --git a/src/components/source-switcher-button/source-switcher-button.scss b/src/components/source-switcher-button/source-switcher-button.scss new file mode 100644 index 000000000..d6fb8e8b3 --- /dev/null +++ b/src/components/source-switcher-button/source-switcher-button.scss @@ -0,0 +1,17 @@ +.vjs-control-bar .vjs-menu-button.vjs-source-switcher-button { + background-image: url("../../assets/icons/source_switcher_icon_for_black_bg.svg"); + background-size: 25px; + background-position: center; + background-repeat: no-repeat; + color: inherit; + opacity: 0.9; + + .cld-video-player-skin-light & { + background-image: url("../../assets/icons/source_switcher_icon_for_white_bg.svg"); + } + + &:hover { + cursor: pointer; + opacity: 1; + } +} diff --git a/src/config/configSchema.json b/src/config/configSchema.json index a2afde1e5..1fcccc17c 100644 --- a/src/config/configSchema.json +++ b/src/config/configSchema.json @@ -492,35 +492,58 @@ ] } } - }, - "title": { - "oneOf": [ - { + }, + "title": { + "oneOf": [ + { + "type": "string", + "default": "" + }, + { + "type": "boolean", + "default": false + } + ] + }, + "description": { + "oneOf": [ + { + "type": "string", + "default": "" + }, + { + "type": "boolean", + "default": false + } + ] + }, + "adaptiveStreaming": { + "type": "string", + "enum": ["fastStart", "balanced", "highQuality"], + "default": "balanced" + }, + "videoSources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { "type": "string", "default": "" }, - { - "type": "boolean", - "default": false - } - ] - }, - "description": { - "oneOf": [ - { + "publicId": { "type": "string", "default": "" - }, - { - "type": "boolean", - "default": false } - ] + }, + "additionalProperties": true }, - "adaptiveStreaming": { - "type": "string", - "enum": ["fastStart", "balanced", "highQuality"], - "default": "balanced" + "default": [ + { + "publicId": "video-public-id", + "label": "Example video label" + } + ] } }, "additionalProperties": true diff --git a/src/plugins/index.js b/src/plugins/index.js index f370a57c2..d254b833c 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -7,6 +7,7 @@ import cloudinary from './cloudinary'; import cloudinaryAnalytics from './cloudinary-analytics'; import contextMenu from './context-menu'; import floatingPlayer from './floating-player'; +import sourceSwitcher from './source-switcher'; import styledTextTracks from './styled-text-tracks'; import vttThumbnails from './vtt-thumbnails'; @@ -30,6 +31,7 @@ const plugins = { cloudinaryAnalytics, contextMenu, floatingPlayer, + sourceSwitcher, styledTextTracks, vttThumbnails, diff --git a/src/plugins/source-switcher/index.js b/src/plugins/source-switcher/index.js new file mode 100644 index 000000000..e46103bdd --- /dev/null +++ b/src/plugins/source-switcher/index.js @@ -0,0 +1,35 @@ +import videojs from 'video.js'; + +function sourceSwitcher() { + const player = this; + + const reInit = (options) => { + const button = player + .getChild('controlBar') + .getChild('sourceSwitcherButton'); + + if (!button) { + videojs.log.warn('SourceSwitcherButton not found in controlBar.'); + return; + } + + const items = options.sources.map((source) => ({ + value: source.publicId, + label: source.label || source.publicId, + })); + + button.setItems(items); + // clear callback before selecting initial element + button.setOnSelected(() => {}); + button.setSelected(options.selectedIndex); + button.setOnSelected(({ index }) => { + options.onSourceChange(options.sources[index]); + }); + }; + + return { + reInit, + }; +} + +export default sourceSwitcher; diff --git a/src/utils/get-analytics-player-options.js b/src/utils/get-analytics-player-options.js index ee9b4ecbc..622d93ffc 100644 --- a/src/utils/get-analytics-player-options.js +++ b/src/utils/get-analytics-player-options.js @@ -36,7 +36,8 @@ const getSourceOptions = (sourceOptions = {}) => ({ sourceTitle: (typeof sourceOptions.title === 'string' ? sourceOptions.title : sourceOptions.info?.title), sourceDescription: (typeof sourceOptions.description === 'string' ? sourceOptions.description : sourceOptions.info?.subtitle || sourceOptions.info?.description) } : {}), - ...(hasConfig(sourceOptions.textTracks) ? getTextTracksOptions(sourceOptions.textTracks) : {}) + ...(hasConfig(sourceOptions.textTracks) ? getTextTracksOptions(sourceOptions.textTracks) : {}), + videoSources: !!sourceOptions.videoSources, }); const getTextTracksOptions = (textTracks = {}) => { diff --git a/src/validators/validators.js b/src/validators/validators.js index 366e21076..30d315c96 100644 --- a/src/validators/validators.js +++ b/src/validators/validators.js @@ -137,5 +137,6 @@ export const sourceValidators = { }, adaptiveStreaming: { strategy: validator.isString(ADAPTIVE_STREAMING_STRATEGY) - } + }, + videoSources: validator.isArray, }; diff --git a/src/video-player.const.js b/src/video-player.const.js index d4e5f83b4..c20a9d1a8 100644 --- a/src/video-player.const.js +++ b/src/video-player.const.js @@ -22,7 +22,8 @@ export const SOURCE_PARAMS = [ 'transformation', 'type', 'visualSearch', - 'withCredentials' + 'withCredentials', + 'videoSources' ]; // All parameters that can be passed to player constructor @@ -50,7 +51,7 @@ export const PLAYER_PARAMS = SOURCE_PARAMS.concat([ 'qualitySelector', 'queryParams', 'seekThumbnails', - 'showJumpControls' + 'showJumpControls', ]); // We support both camelCase and snake_case for cloudinary SDK params @@ -58,7 +59,7 @@ export const CLOUDINARY_CONFIG_PARAM = [ 'api_secret', 'auth_token', 'cdn_subdomain', - 'cloud_name', + 'cloud_name', 'cname', 'private_cdn', 'secure', diff --git a/src/video-player.js b/src/video-player.js index c834375b5..86ad21ea6 100644 --- a/src/video-player.js +++ b/src/video-player.js @@ -198,6 +198,7 @@ class VideoPlayer extends Utils.mixin(Eventable) { this._initSeekThumbs(); this._initChapters(); this._initInteractionAreas(); + this._initSourceSwitcher(); } _isFullScreen() { @@ -446,6 +447,45 @@ class VideoPlayer extends Utils.mixin(Eventable) { } } + _initSourceSwitcher() { + this.sourceSwitcher = this.videojs.sourceSwitcher(); + + this.videojs.on(PLAYER_EVENT.CLD_SOURCE_CHANGED, (e, { source }) => { + const videoSources = source.videoSources?.(); + const isSourcesListAvailable = Array.isArray(videoSources) ? !!videoSources.length : false; + + if (this.videojs.controlBar) { + const method = isSourcesListAvailable ? 'show' : 'hide'; + const element = this.videojs.controlBar.getChild('sourceSwitcherButton'); + + if (element && typeof element?.[method] === 'function') { + element[method](); + } + } + + if (isSourcesListAvailable) { + const selectedIndex = videoSources.findIndex(({ publicId }) => publicId === source.publicId()); + this.sourceSwitcher.reInit({ + sources: videoSources, + selectedIndex: selectedIndex === -1 ? 0 : selectedIndex, + onSourceChange: ({ publicId, ...newSourceOptions }) => this.source(publicId, { + ...newSourceOptions, + videoSources, + }), + }); + } + }); + + if (Array.isArray(this.playerOptions.sourceOptions?.videoSources) && this.playerOptions.sourceOptions?.videoSources.length) { + // eslint-disable-next-line no-unused-vars + const { publicId, label, ...videoSourceData } = this.playerOptions.sourceOptions.videoSources[0]; + this.source(publicId, { + ...videoSourceData, + videoSources: this.playerOptions.sourceOptions.videoSources, + }); + } + } + reTryVideoStateUntilAvailable(maxNumberOfCalls = Number.POSITIVE_INFINITY, timeout = RETRY_DEFAULT_TIMEOUT) { if (typeof this.reTryVideoStateRetriesCount !== 'number') { this.reTryVideoStateRetriesCount = 0; diff --git a/src/video-player.utils.js b/src/video-player.utils.js index 187f54755..d1665d748 100644 --- a/src/video-player.utils.js +++ b/src/video-player.utils.js @@ -92,7 +92,7 @@ export const extractOptions = (elem, options) => { // Cloudinary SDK config (cloud_name, secure, etc.) playerOptions.cloudinary = Utils.sliceAndUnsetProperties(playerOptions, ...CLOUDINARY_CONFIG_PARAM); - + // Merge with cloudinaryConfig from src/index.js (e.g., secureDistribution -> secure_distribution) if (playerOptions.cloudinaryConfig) { Object.assign(playerOptions.cloudinary, playerOptions.cloudinaryConfig); @@ -124,6 +124,7 @@ export const overrideDefaultVideojsComponents = () => { const ControlBar = videojs.getComponent('ControlBar'); if (ControlBar) { children = ControlBar.prototype.options_.children; + // Add space instead of the progress control (which we detached from the controlBar, and absolutely positioned it above it) // Also add a blank div underneath the progress control to stop bubbling up pointer events. children.splice(children.indexOf('progressControl'), 0, 'spacer', 'progressControlEventsBlocker'); @@ -131,6 +132,8 @@ export const overrideDefaultVideojsComponents = () => { // Add skip buttons around the 'play-toggle' children.splice(children.indexOf('playToggle'), 1, 'playToggle', 'JumpBackButton', 'JumpForwardButton'); + children.splice(children.indexOf('chaptersButton'), 1, 'sourceSwitcherButton', 'chaptersButton'); + // Position the 'logo-button' button last children.push('logoButton'); diff --git a/test/e2e/specs/NonESM/linksConsolErros.spec.ts b/test/e2e/specs/NonESM/linksConsolErros.spec.ts index 16a8a89ad..d698de3eb 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 = 38; + const expectedNumberOfLinks = 39; const numberOfLinks = await page.getByRole('link').count(); expect(numberOfLinks).toBe(expectedNumberOfLinks); }); diff --git a/test/e2e/specs/NonESM/sourceSwitcherPage.spec.ts b/test/e2e/specs/NonESM/sourceSwitcherPage.spec.ts new file mode 100644 index 000000000..732254be1 --- /dev/null +++ b/test/e2e/specs/NonESM/sourceSwitcherPage.spec.ts @@ -0,0 +1,8 @@ +import { vpTest } from '../../fixtures/vpTest'; +import { getLinkByName } from '../../testData/pageLinksData'; +import { ExampleLinkName } from '../../testData/ExampleLinkNames'; + +const link = getLinkByName(ExampleLinkName.SourceSwitcher); + +vpTest(`[MOCKED - TODO] Test if video on source switcher page is playing as expected`, async ({ page, pomPages }) => { +}); diff --git a/test/e2e/testData/ExampleLinkNames.ts b/test/e2e/testData/ExampleLinkNames.ts index 32237e764..5c7ec42c7 100644 --- a/test/e2e/testData/ExampleLinkNames.ts +++ b/test/e2e/testData/ExampleLinkNames.ts @@ -37,6 +37,7 @@ export enum ExampleLinkName { ESMImports = 'ESM Imports', AllBuild = '/all build', ShareAndDownload = 'Share & Download', + SourceSwitcher = 'Source switcher', VisualSearch = 'Visual Search', VideoDetails = 'Video Details', }