diff --git a/packages/styles/scss/components/content-group-cards/_content-group-cards.scss b/packages/styles/scss/components/content-group-cards/_content-group-cards.scss index 76fe8147c90..d29d39a2f88 100644 --- a/packages/styles/scss/components/content-group-cards/_content-group-cards.scss +++ b/packages/styles/scss/components/content-group-cards/_content-group-cards.scss @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 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. diff --git a/packages/styles/scss/components/video-player/_video-player.scss b/packages/styles/scss/components/video-player/_video-player.scss index 2ce8da05989..28a12355d18 100644 --- a/packages/styles/scss/components/video-player/_video-player.scss +++ b/packages/styles/scss/components/video-player/_video-player.scss @@ -13,9 +13,17 @@ @use '@carbon/styles/scss/spacing' as *; @use '@carbon/styles/scss/theme' as *; @use '@carbon/styles/scss/type' as *; +@use '@carbon/styles/scss/utilities/convert' as *; +@use '@carbon/styles/scss/components/button/vars' as *; $aspect-ratios: ((16, 9), (9, 16), (2, 1), (1, 2), (4, 3), (3, 4), (1, 1)); @mixin video-player { + // Make the video player container a block for purposes of the intersection + // observer. + :host(#{$c4d-prefix}-video-player-container) { + display: block; + } + :host(#{$c4d-prefix}-video-player), .#{$c4d-prefix}--video-player { color: var(--#{$c4d-prefix}--video-caption--color, $text-secondary); @@ -25,19 +33,6 @@ $aspect-ratios: ((16, 9), (9, 16), (2, 1), (1, 2), (4, 3), (3, 4), (1, 1)); inline-size: 100%; } - &:focus { - outline: none; - .#{$c4d-prefix}--video-player__video-container { - &::before { - position: absolute; - z-index: 1; - border: 1px solid $focus-inverse; - content: ' '; - inset: $spacing-01; - outline: $spacing-01 solid $focus; - } - } - } #{$c4d-prefix}-image { padding-block-start: 0; } @@ -128,6 +123,8 @@ $aspect-ratios: ((16, 9), (9, 16), (2, 1), (1, 2), (4, 3), (3, 4), (1, 1)); &.#{$c4d-prefix}--video-player__aspect-ratio { &--#{$width}x#{$height} { @include ratio-base($width, $height, true); + + overflow: visible; } } } @@ -201,4 +198,54 @@ $aspect-ratios: ((16, 9), (9, 16), (2, 1), (1, 2), (4, 3), (3, 4), (1, 1)); } } } + + .#{$c4d-prefix}--video-player__toggle-playback { + position: absolute; + z-index: 100; + padding: 0.875rem; + border: 0; + background-color: $overlay; + block-size: $spacing-09; + color: #ffffff; + inline-size: $spacing-09; + + &--top-left { + inset-block-start: 0; + inset-inline-start: 0; + } + + &--top-right { + inset-block-start: 0; + inset-inline-end: 0; + } + + &--bottom-right { + inset-block-end: 0; + inset-inline-end: 0; + } + + &--bottom-left { + inset-block-end: 0; + inset-inline-start: 0; + } + + /* stylelint-disable-next-line caem/require-color-with-bg */ + &:hover { + // Grey 100, more opaque. + background-color: rgba(22, 22, 22, 0.9); + cursor: pointer; + } + + &:focus { + outline: 2px solid $focus; + } + } + + // Prevent any pointer events from getting through to the slotted player div + // when in intersection mode. + :host(#{$c4d-prefix}-video-player[intersection-mode]) { + ::slotted(.#{$c4d-prefix}--video-player__video) { + pointer-events: none; + } + } } diff --git a/packages/web-components/src/components/card-in-card/__stories__/card-in-card.stories.ts b/packages/web-components/src/components/card-in-card/__stories__/card-in-card.stories.ts index 5e3f830af79..fd5ae9ea2a5 100644 --- a/packages/web-components/src/components/card-in-card/__stories__/card-in-card.stories.ts +++ b/packages/web-components/src/components/card-in-card/__stories__/card-in-card.stories.ts @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2021, 2023 + * Copyright IBM Corp. 2021, 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. @@ -12,9 +12,12 @@ import '../../image/image'; import '../index'; import '../../cta/card-cta-footer'; import '../../cta/video-cta-container'; +import '../../carousel/carousel'; +import '../../video-player/video-player-container'; import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { boolean } from '@storybook/addon-knobs'; +import { boolean, select, text } from '@storybook/addon-knobs'; +import ArrowRight20 from '@carbon/web-components/es/icons/arrow--right/20.js'; import imgXlg16x9 from '../../../../.storybook/storybook-images/assets/1312/fpo--16x9--1312x738--005.jpg'; import imgMd16x9 from '../../../../.storybook/storybook-images/assets/960/fpo--16x9--960x540--005.jpg'; @@ -22,6 +25,8 @@ import imgSm4x3 from '../../../../.storybook/storybook-images/assets/480/fpo--4x import readme from './README.stories.mdx'; import textNullable from '../../../../.storybook/knob-text-nullable'; +import { BUTTON_POSITION } from '../../video-player/defs'; +import { enumValsToArray } from '../../../globals/internal/enum-helpers'; export const Default = (args) => { const { video, eyebrow, heading, defaultSrc, alt, href } = @@ -60,23 +65,8 @@ export const Default = (args) => { `; }; -export default { - title: 'Components/Card in card', - decorators: [ - (story) => html` -
-
-
${story()}
-
-
- `, - ], +Default.story = { parameters: { - percy: { - skip: true, - }, - ...readme.parameters, - hasStoryPadding: true, knobs: { 'c4d-card-in-card': () => { const video = boolean('video', false); @@ -105,6 +95,93 @@ export default { }; }, }, + }, +}; + +export const WithCarouselVideo = (args) => { + const { videoId, thumbnail, buttonPosition } = + args?.['WithCarouselVideo'] ?? {}; + + return html` + + + Experience // Case Study + Putting innovation in the driver's seat + Link ${ArrowRight20({ slot: 'icon' })} + + + + + Experience // Case Study + Putting innovation in the driver's seat + Link ${ArrowRight20({ slot: 'icon' })} + + + + `; +}; + +WithCarouselVideo.story = { + name: 'With carousel video', + parameters: { + knobs: { + WithCarouselVideo: () => { + return { + video: null, + videoId: text('Video id (videoId):', '0_ibuqxqbe'), + thumbnail: text('Custom thumbnail (thumbnail):', ''), + buttonPosition: select( + 'Button position (buttonPosition)', + enumValsToArray(BUTTON_POSITION), + BUTTON_POSITION.BOTTOM_LEFT + ), + }; + }, + }, + }, +}; + +export default { + title: 'Components/Card in card', + decorators: [ + (story) => html` +
+
+
${story()}
+
+
+ `, + ], + parameters: { + percy: { + skip: true, + }, + ...readme.parameters, + hasStoryPadding: true, propsSet: { default: { 'c4d-card-in-card': { diff --git a/packages/web-components/src/components/lightbox-media-viewer/lightbox-video-player-composite.ts b/packages/web-components/src/components/lightbox-media-viewer/lightbox-video-player-composite.ts index 9bc896bae03..2e0ffa8ca97 100644 --- a/packages/web-components/src/components/lightbox-media-viewer/lightbox-video-player-composite.ts +++ b/packages/web-components/src/components/lightbox-media-viewer/lightbox-video-player-composite.ts @@ -183,7 +183,7 @@ class C4DLightboxVideoPlayerComposite extends ModalRenderMixin( if (videoId) { this._loadVideoData?.(videoId); if (open) { - this._embedMedia?.(videoId, false); + this._embedMedia?.(videoId); this._handleAriaAndHiddenState(); } } diff --git a/packages/web-components/src/components/video-player/__stories__/video-player.stories.ts b/packages/web-components/src/components/video-player/__stories__/video-player.stories.ts index 2c04e1a1904..ce2bdb965b4 100644 --- a/packages/web-components/src/components/video-player/__stories__/video-player.stories.ts +++ b/packages/web-components/src/components/video-player/__stories__/video-player.stories.ts @@ -8,11 +8,13 @@ */ import { html } from 'lit'; -import { boolean, text } from '@storybook/addon-knobs'; +import { boolean, text, select } from '@storybook/addon-knobs'; import { ifDefined } from 'lit/directives/if-defined.js'; import readme from './README.stories.mdx'; import '../video-player-container'; import '../../lightbox-media-viewer/lightbox-video-player-container'; +import { enumValsToArray } from '../../../globals/internal/enum-helpers'; +import { BUTTON_POSITION } from '../defs'; export const Default = (args) => { const { caption, hideCaption, thumbnail, videoId } = args?.VideoPlayer ?? {}; @@ -118,6 +120,26 @@ export const autoplayMuted = (args) => { `; }; +export const intersectionMode = (args) => { + const { + aspectRatio, + caption, + hideCaption, + thumbnail, + videoId, + buttonPosition, + } = args?.VideoPlayer ?? {}; + return html` `; +}; + aspectRatio4x3.story = { name: 'Aspect ratio 4:3', parameters: { @@ -263,6 +285,49 @@ autoplayMuted.story = { }, }; +intersectionMode.story = { + name: 'Intersection mode', + decorators: [ + (story) => html` +

+ 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` -
- -
- ` - : html` `; - } + protected _renderContent = () => { + const { + contentState, + name, + thumbnailUrl, + backgroundMode, + _handleClickOverlay: handleClickOverlay, + intersectionMode, + } = this; + if (intersectionMode) { + return html` +
+ ${contentState === VIDEO_PLAYER_CONTENT_STATE.THUMBNAIL + ? html` + + + ` + : html` `} +
+ `; + } else { + return contentState === VIDEO_PLAYER_CONTENT_STATE.THUMBNAIL && + !backgroundMode && + !this.autoplay + ? html` +
+ +
+ ` + : html` `; + } + }; /** * Updates video thumbnail url to match video width */ - private _updateThumbnailUrl() { + protected _updateThumbnailUrl() { let thumbnailSrc: false | URL = false; try { @@ -229,6 +290,10 @@ class C4DVideoPlayer extends FocusMixin(StableSelectorMixin(LitElement)) { formatDuration, hideCaption, name, + buttonPosition, + intersectionMode, + _handleTogglePlayback: handleTogglePlayback, + _renderContent: renderContent, } = this; const aspectRatioClass = classMap({ @@ -236,10 +301,27 @@ class C4DVideoPlayer extends FocusMixin(StableSelectorMixin(LitElement)) { [`${c4dPrefix}--video-player__aspect-ratio--${aspectRatio}`]: !!aspectRatio, }); + const togglePlaybackClass = classMap({ + [`${c4dPrefix}--video-player__toggle-playback`]: true, + [`${c4dPrefix}--video-player__toggle-playback--${buttonPosition}`]: true, + }); return html` -
- ${this._renderContent()} +
+ ${intersectionMode + ? html` + + ` + : null} + ${renderContent()}
${hideCaption ? undefined @@ -288,16 +370,15 @@ class C4DVideoPlayer extends FocusMixin(StableSelectorMixin(LitElement)) { firstUpdated() { this.tabIndex = 0; - const parentIsBackground = Boolean( - (this.parentElement as C4DVideoPlayerContainer)?.backgroundMode + this.backgroundMode = Boolean( + (this.parentElement as C4DVideoPlayerComposite)?.backgroundMode ); - - const parentIsAutoplay = Boolean( - (this.parentElement as C4DVideoPlayerContainer)?.autoPlay + this.intersectionMode = Boolean( + (this.parentElement as C4DVideoPlayerComposite)?.intersectionMode + ); + this.autoplay = Boolean( + (this.parentElement as C4DVideoPlayerComposite)?.autoPlay ); - - this.backgroundMode = parentIsBackground; - this.autoplay = parentIsAutoplay; } /** @@ -318,6 +399,13 @@ class C4DVideoPlayer extends FocusMixin(StableSelectorMixin(LitElement)) { return `${c4dPrefix}--video-player`; } + /** + * The name of the custom event fired when a user action toggles playback. + */ + static get eventTogglePlayback() { + return `${c4dPrefix}-video-player-toggle-playback`; + } + static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true, diff --git a/packages/web-components/src/globals/internal/enum-helpers.ts b/packages/web-components/src/globals/internal/enum-helpers.ts new file mode 100644 index 00000000000..7128fb9bb9a --- /dev/null +++ b/packages/web-components/src/globals/internal/enum-helpers.ts @@ -0,0 +1,88 @@ +/** + * @license + * + * Copyright IBM Corp. 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. + */ + +const defaultOpts = { delimeter: '_' }; + +/** + * Storybook doesn't handle empty strings (i.e. '') well in Control configuration. + * Instead, we replace empty strings in enums with this blank space. + */ +const STORYBOOK_EMPTY_STRING = ' '; + +/** + * Capitalizes a word. + * + * @param {string} word The word to capitalize. + * @returns {string} The capitalized word. + */ +const capitalize = (word: string) => + `${word.slice(0, 1).toUpperCase()}${word + .slice(1, word.length) + .toLowerCase()}`; + +/** + * Formats an enum key as a user-friendly label for use in Storybook. + * + * @param {string} key The enum key. + * @param {object} opts Optional configuration. + * @param {string} opts.delimeter The character to split on. + * @returns {string} The formatted version of the provided key. + */ +export const formatEnumKey = (key, opts = defaultOpts) => + key + .split(opts.delimeter) + .map((word) => capitalize(word)) + .join(' '); + +/** + * Conversts a TypeScript enum into an array. + * + * @param en The enum to convert. + * @returns {Array} An array containing the enum's values. + */ +export const enumValsToArray = (en) => + Object.keys(en) + .filter((key) => typeof key === 'string') + .map((key) => en[key] || STORYBOOK_EMPTY_STRING); + +/** + * Converts a TypeScript enum's keys into an array of formatted labels. + * + * @param en The enum to convert. + * @param {object} opts Optional configuration. + * @param {string} opts.delimeter The character to split on. + * @returns {Array} An array containing formatted versions of the enum's keys. + */ +export const enumKeysToArray = (en, opts = defaultOpts) => + Object.keys(en) + .filter((key) => typeof key === 'string') + .map((key) => formatEnumKey(key, opts)); + +/** + * Converts an enum into a Storybook-friendly labels object. + * + * @param en The enum to convert. + * @param {object} opts Optional configuration. + * @param {string} opts.delimeter The character to split on. + * @returns {object} An object for use in Storybook Control label config. + */ +export const enumToLabels = (en, opts = defaultOpts) => { + const options = {}; + Object.keys(en) + .filter((key) => typeof key === 'string') + .forEach((key) => { + const value = en[key]; + const keyFormatted = formatEnumKey(key, opts); + options[`${value || STORYBOOK_EMPTY_STRING}`] = `${keyFormatted}${ + value && ` (${value})` + }`; + }); + + return options; +};