+ Scroll down ⬇️
+ To illustrate playback beginning only when the video comes into view,
+ we've added intentional space to push the video below the fold.
+
+
${story()}
+ `,
+ ],
+ parameters: {
+ knobs: {
+ VideoPlayer: () => {
+ return {
+ aspectRatio: '16x9',
+ caption: text('Custom caption (caption):', ''),
+ hideCaption: boolean('Hide caption (hideCaption):', false),
+ thumbnail: text('Custom thumbnail (thumbnail):', ''),
+ videoId: '0_ibuqxqbe',
+ buttonPosition: select(
+ 'Button position (buttonPosition)',
+ enumValsToArray(BUTTON_POSITION),
+ BUTTON_POSITION.BOTTOM_RIGHT
+ ),
+ };
+ },
+ },
+ propsSet: {
+ default: {
+ VideoPlayer: {
+ aspectRatio: '16x9',
+ caption: '',
+ hideCaption: false,
+ thumbnail: '',
+ videoId: '0_ibuqxqbe',
+ },
+ },
+ },
+ },
+};
+
export default {
title: 'Components/Video player',
decorators: [
diff --git a/packages/web-components/src/components/video-player/defs.ts b/packages/web-components/src/components/video-player/defs.ts
index dbcdd72adf8..d236fc64e8a 100644
--- a/packages/web-components/src/components/video-player/defs.ts
+++ b/packages/web-components/src/components/video-player/defs.ts
@@ -1,7 +1,7 @@
/**
* @license
*
- * Copyright IBM Corp. 2020, 2021
+ * Copyright IBM Corp. 2020, 2024
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
@@ -35,3 +35,10 @@ export enum VIDEO_PLAYER_PLAYING_MODE {
*/
LIGHTBOX = 'lightbox',
}
+
+export enum BUTTON_POSITION {
+ TOP_LEFT = 'top-left',
+ TOP_RIGHT = 'top-right',
+ BOTTOM_RIGHT = 'bottom-right',
+ BOTTOM_LEFT = 'bottom-left',
+}
diff --git a/packages/web-components/src/components/video-player/video-player-composite.ts b/packages/web-components/src/components/video-player/video-player-composite.ts
index a6f3fa1e61a..d2bec2aabf1 100644
--- a/packages/web-components/src/components/video-player/video-player-composite.ts
+++ b/packages/web-components/src/components/video-player/video-player-composite.ts
@@ -8,8 +8,7 @@
*/
import { LitElement, html } from 'lit';
-import { property } from 'lit/decorators.js';
-import { ifDefined } from 'lit/directives/if-defined.js';
+import { property, state } from 'lit/decorators.js';
import HostListener from '@carbon/web-components/es/globals/decorators/host-listener.js';
import HostListenerMixin from '@carbon/web-components/es/globals/mixins/host-listener.js';
import settings from '@carbon/ibmdotcom-utilities/es/utilities/settings/settings.js';
@@ -23,6 +22,8 @@ import {
// Above import is interface-only ref and thus code won't be brought into the build
import './video-player';
import { carbonElement as customElement } from '@carbon/web-components/es/globals/decorators/carbon-element.js';
+import { BUTTON_POSITION } from './defs';
+import ifNonEmpty from '@carbon/web-components/es/globals/directives/if-non-empty.js';
const { stablePrefix: c4dPrefix } = settings;
@@ -48,7 +49,7 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
*
* @internal
*/
- _embedMedia?: (videoId: string, backgroundMode?: boolean) => Promise;
+ _embedMedia?: (videoId: string) => Promise;
/**
* The placeholder for `_setAutoplayPreference()` Redux action that may be mixed in.
@@ -85,6 +86,79 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
return this.querySelector(selectorVideoPlayer);
}
+ /**
+ * Clean-up and create intersection observers.
+ *
+ * When this.intersectionMode, we use intersection observers to track when
+ * the video container is in view, and embed / play / pause the video
+ * accordingly.
+ *
+ * @param [options] The options.
+ * @param [options.create] `true` to create necessary intersection observers.
+ */
+ private _cleanAndCreateObserverIntersection({
+ create,
+ }: { create?: boolean } = {}) {
+ // Cleanup.
+ if (this._observerIntersectionIntoView) {
+ this._observerIntersectionIntoView.unobserve(this);
+ }
+ if (this._observerIntersectionOutOfView) {
+ this._observerIntersectionOutOfView.unobserve(this);
+ }
+ // Create new intersection observers.
+ if (create) {
+ this._observerIntersectionIntoView = new IntersectionObserver(
+ this._intersectionIntoViewHandler.bind(this),
+ {
+ root: this.closest('c4d-carousel'),
+ rootMargin: '0px',
+ threshold: 0.9,
+ }
+ );
+ this._observerIntersectionOutOfView = new IntersectionObserver(
+ this._intersectionOutOfViewHandler.bind(this),
+ {
+ root: this.closest('c4d-carousel'),
+ rootMargin: '0px',
+ threshold: 0.5,
+ }
+ );
+ this._observerIntersectionIntoView.observe(this);
+ this._observerIntersectionOutOfView.observe(this);
+ }
+ }
+
+ /**
+ * Observer for when the video container enters into view.
+ *
+ * Autoplay the video, resecting the users stored autoplay preference.
+ */
+ private _intersectionIntoViewHandler(entries: IntersectionObserverEntry[]) {
+ const { videoId } = this;
+ entries.forEach((entry) => {
+ if (entry.isIntersecting && this._getAutoplayPreference() !== false) {
+ this._embedMedia?.(videoId);
+ this.playAllVideos();
+ }
+ });
+ }
+
+ /**
+ * Observer for when the video container goes out of view.
+ *
+ * Auto-pause the video, video playback controlled by intersection observers
+ * here are meant to be ambient, without audio. No reason for playback when
+ * user is not seeing the video content.
+ */
+ private _intersectionOutOfViewHandler(entries: IntersectionObserverEntry[]) {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) {
+ this.pauseAllVideos(false);
+ }
+ });
+ }
+
/**
* Handles `c4d-video-player-content-state-changed` event.
* Such event is fired when user changes video content state, e.g. from thumbnail to video player.
@@ -99,7 +173,7 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
playingMode === VIDEO_PLAYER_PLAYING_MODE.INLINE &&
videoId
) {
- this._embedMedia?.(videoId, this.backgroundMode);
+ this._embedMedia?.(videoId);
}
}
@@ -117,26 +191,48 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
}
this._setAutoplayPreference(this.isPlaying);
+ this.playbackTriggered = true;
}
- pauseAllVideos() {
+ @HostListener('eventTogglePlayback')
+ protected _handleEventTogglePlayback(event: CustomEvent) {
+ const { videoId } = event.detail;
+ if (videoId) {
+ this._setAutoplayPreference(!this.isPlaying);
+
+ // First ensure that the media has actually been embedded.
+ this._embedMedia?.(videoId);
+ if (this.isPlaying) {
+ this.pauseAllVideos();
+ } else {
+ this.playAllVideos();
+ }
+ }
+ }
+
+ pauseAllVideos(updateAutoplayPreference = true) {
const { embeddedVideos = {} } = this;
Object.keys(embeddedVideos).forEach((videoId) => {
embeddedVideos[videoId].sendNotification('doPause');
});
this.isPlaying = false;
- this._setAutoplayPreference(false);
+ if (updateAutoplayPreference) {
+ this._setAutoplayPreference(false);
+ }
}
- playAllVideos() {
+ playAllVideos(updateAutoplayPreference = true) {
const { embeddedVideos = {} } = this;
Object.keys(embeddedVideos).forEach((videoId) => {
embeddedVideos[videoId].sendNotification('doPlay');
});
this.isPlaying = true;
- this._setAutoplayPreference(true);
+ this.playbackTriggered = true;
+ if (updateAutoplayPreference) {
+ this._setAutoplayPreference(true);
+ }
}
/**
@@ -201,6 +297,24 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
@property({ type: Boolean, attribute: 'background-mode', reflect: true })
backgroundMode = false;
+ /**
+ * Triggers playback on intersection with the viewport / carousel.
+ */
+ @property({ attribute: 'intersection-mode', reflect: true, type: Boolean })
+ intersectionMode = false;
+
+ /**
+ * The position of the toggle playback button.
+ */
+ @property({ attribute: 'button-position', reflect: true })
+ buttonPosition = BUTTON_POSITION.BOTTOM_RIGHT;
+
+ /**
+ * Track when we have triggered initial playback.
+ */
+ @state()
+ playbackTriggered = false;
+
/**
* The video data, keyed by the video ID.
*/
@@ -243,6 +357,16 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
@property({ type: Number, attribute: 'video-thumbnail-width' })
videoThumbnailWidth = 3;
+ /**
+ * Observe when the video container enters into view.
+ */
+ private _observerIntersectionIntoView?: IntersectionObserver;
+
+ /**
+ * Observe when the video container goes out of view.
+ */
+ private _observerIntersectionOutOfView?: IntersectionObserver;
+
connectedCallback() {
super.connectedCallback();
@@ -259,6 +383,15 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
this.isPlaying = storedPreference;
}
}
+
+ if (this.intersectionMode) {
+ this._cleanAndCreateObserverIntersection({ create: true });
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._cleanAndCreateObserverIntersection();
}
updated(changedProperties) {
@@ -268,7 +401,7 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
if (videoId) {
this._loadVideoData?.(videoId);
if (autoPlay || backgroundMode) {
- this._embedMedia?.(videoId, backgroundMode);
+ this._embedMedia?.(videoId);
}
}
}
@@ -287,6 +420,7 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
videoThumbnailWidth,
thumbnail,
playingMode,
+ buttonPosition,
} = this;
const { [videoId]: currentVideoData = {} as MediaData } = mediaData;
const { duration, name } = currentVideoData;
@@ -299,16 +433,21 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
return html`
+ name="${ifNonEmpty(caption || name)}"
+ video-description="${ifNonEmpty(customVideoDescription)}"
+ thumbnail-url="${ifNonEmpty(thumbnailUrl)}"
+ video-id="${ifNonEmpty(videoId)}"
+ aspect-ratio="${ifNonEmpty(aspectRatio)}"
+ playing-mode="${ifNonEmpty(playingMode)}"
+ content-state="${this.playbackTriggered
+ ? VIDEO_PLAYER_CONTENT_STATE.VIDEO
+ : VIDEO_PLAYER_CONTENT_STATE.THUMBNAIL}"
+ button-position="${buttonPosition}"
+ .formatCaption="${ifNonEmpty(formatCaption)}"
+ .formatDuration="${ifNonEmpty(formatDuration)}"
+ .isPlaying=${this.isPlaying}>
`;
}
@@ -337,6 +476,13 @@ class C4DVideoPlayerComposite extends HybridRenderMixin(
static get eventPlaybackStateChange() {
return `${c4dPrefix}-video-player-playback-state-changed`;
}
+
+ /**
+ * The name of the custom event fired requesting to toggle playback.
+ */
+ static get eventTogglePlayback() {
+ return `${c4dPrefix}-video-player-toggle-playback`;
+ }
}
/* @__GENERATE_REACT_CUSTOM_ELEMENT_TYPE__ */
diff --git a/packages/web-components/src/components/video-player/video-player-container.ts b/packages/web-components/src/components/video-player/video-player-container.ts
index d725b49bbac..4bff6534a19 100644
--- a/packages/web-components/src/components/video-player/video-player-container.ts
+++ b/packages/web-components/src/components/video-player/video-player-container.ts
@@ -177,7 +177,7 @@ export const C4DVideoPlayerContainerMixin = <
}
_getPlayerOptions() {
- const { backgroundMode, autoPlay, muted } =
+ const { backgroundMode, intersectionMode, autoPlay, muted } =
this as unknown as C4DVideoPlayerComposite;
let playerOptions = {};
const autoplayPreference = this._getAutoplayPreference();
@@ -190,7 +190,7 @@ export const C4DVideoPlayerContainerMixin = <
};
break;
- case backgroundMode:
+ case backgroundMode || intersectionMode:
playerOptions = {
'topBarContainer.plugin': false,
'controlBarContainer.plugin': false,
diff --git a/packages/web-components/src/components/video-player/video-player.ts b/packages/web-components/src/components/video-player/video-player.ts
index e4790a1062c..aa16107ef8f 100644
--- a/packages/web-components/src/components/video-player/video-player.ts
+++ b/packages/web-components/src/components/video-player/video-player.ts
@@ -10,21 +10,27 @@
import { LitElement, html } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
-import { ifDefined } from 'lit/directives/if-defined.js';
import FocusMixin from '@carbon/web-components/es/globals/mixins/focus.js';
import PlayVideo from '../../../es/icons/play-video.js';
+import PlayOutline from '@carbon/web-components/es/icons/play--outline/20.js';
+import PauseOutline from '@carbon/web-components/es/icons/pause--outline/20.js';
import {
formatVideoCaption,
formatVideoDuration,
} from '@carbon/ibmdotcom-utilities/es/utilities/formatVideoCaption/formatVideoCaption.js';
import settings from '@carbon/ibmdotcom-utilities/es/utilities/settings/settings.js';
import KalturaPlayerAPI from '@carbon/ibmdotcom-services/es/services/KalturaPlayer/KalturaPlayer.js';
-import { VIDEO_PLAYER_CONTENT_STATE, VIDEO_PLAYER_PLAYING_MODE } from './defs';
+import {
+ BUTTON_POSITION,
+ VIDEO_PLAYER_CONTENT_STATE,
+ VIDEO_PLAYER_PLAYING_MODE,
+} from './defs';
import '../image/image';
import styles from './video-player.scss';
import StableSelectorMixin from '../../globals/mixins/stable-selector';
-import C4DVideoPlayerContainer from './video-player-container';
import { carbonElement as customElement } from '@carbon/web-components/es/globals/decorators/carbon-element.js';
+import ifNonEmpty from '@carbon/web-components/es/globals/directives/if-non-empty.js';
+import C4DVideoPlayerComposite from './video-player-composite';
export { VIDEO_PLAYER_CONTENT_STATE };
export { VIDEO_PLAYER_PLAYING_MODE };
@@ -49,10 +55,28 @@ class C4DVideoPlayer extends FocusMixin(StableSelectorMixin(LitElement)) {
@property({ reflect: true, attribute: 'playing-mode' })
playingMode = VIDEO_PLAYER_PLAYING_MODE.INLINE;
+ /**
+ * Triggers playback on intersection with the viewport / carousel.
+ */
+ @property({ attribute: 'intersection-mode', reflect: true, type: Boolean })
+ intersectionMode = false;
+
+ /**
+ * The current playback state, inherited from the parent.
+ */
+ @property()
+ isPlaying = false;
+
+ /**
+ * The position of the toggle playback button.
+ */
+ @property({ attribute: 'button-position', reflect: true })
+ buttonPosition = BUTTON_POSITION.BOTTOM_RIGHT;
+
/**
* Handles `click` event on the video thumbnail.
*/
- private _handleClickOverlay() {
+ protected _handleClickOverlay = () => {
if (this.playingMode === VIDEO_PLAYER_PLAYING_MODE.INLINE) {
this.contentState = VIDEO_PLAYER_CONTENT_STATE.VIDEO;
}
@@ -72,38 +96,75 @@ class C4DVideoPlayer extends FocusMixin(StableSelectorMixin(LitElement)) {
},
})
);
- }
+ };
+
+ protected _handleTogglePlayback = () => {
+ const { videoId } = this;
+ const { eventTogglePlayback } = this.constructor as typeof C4DVideoPlayer;
+ this.dispatchEvent(
+ new CustomEvent(eventTogglePlayback, {
+ bubbles: true,
+ composed: true,
+ detail: {
+ videoId,
+ },
+ })
+ );
+ };
/**
* @returns The video content.
*/
- private _renderContent() {
- const { contentState, name, thumbnailUrl, backgroundMode } = this;
- return contentState === VIDEO_PLAYER_CONTENT_STATE.THUMBNAIL &&
- !backgroundMode &&
- !this.autoplay
- ? html`
-