diff --git a/examples/vanilla-ts-esm/public/mux-player-google-ima.html b/examples/vanilla-ts-esm/public/mux-player-google-ima.html new file mode 100644 index 000000000..e8b90e421 --- /dev/null +++ b/examples/vanilla-ts-esm/public/mux-player-google-ima.html @@ -0,0 +1,73 @@ + + + + + <mux-player> Google IMA CSAI example + + + + + + + +
+
+ +

Elements

+
+
+ +
+
+ + + + + Browse Elements + + diff --git a/packages/mux-player/src/index.ts b/packages/mux-player/src/index.ts index 279c31c23..114c1469a 100644 --- a/packages/mux-player/src/index.ts +++ b/packages/mux-player/src/index.ts @@ -84,6 +84,8 @@ const PlayerAttributes = { NO_VOLUME_PREF: 'no-volume-pref', CAST_RECEIVER: 'cast-receiver', NO_TOOLTIPS: 'no-tooltips', + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + AD_TAG_URL: 'adtagurl', }; const ThemeAttributeNames = [ @@ -172,6 +174,14 @@ function getProps(el: MuxPlayerElement, state?: any): MuxTemplateProps { // NOTE: since the attribute value is used as the "source of truth" for the property getter, // moving this below the `...state` spread so it resolves to the default value when unset (CJP) extraSourceParams: el.extraSourceParams, + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + adTagUrl: el.adTagUrl, + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + adBreak: el.adBreak, + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + adBreakTotalAds: el.adBreakTotalAds, + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + adBreakAdPosition: el.adBreakAdPosition, }; return props; @@ -365,6 +375,35 @@ class MuxPlayerElement extends VideoApiElement implements MuxPlayerElement { // NOTE: Make sure we re-render when tags are appended so hasSrc is updated. this.media?.addEventListener('loadstart', () => this.#render()); + + /** @TODO remove me when migrated to media chrome */ + this.media?.addEventListener('adbreakchange', () => { + // MediaUIEvents.MEDIA_EXIT_PIP_REQUEST + this.mediaController?.dispatchEvent(new CustomEvent('mediaexitpiprequest')); + this.#render(); + }); + this.media?.addEventListener('adbreakadpositionchange', () => { + this.#render(); + }); + this.media?.addEventListener('adbreaktotaladschange', () => { + this.#render(); + }); + this.mediaController?.addEventListener('mediaisfullscreen', () => { + const { mediaIsFullscreen = false } = this.mediaController?.mediaStore.getState() ?? {}; + /** @TODO Figure out API design (CJP) */ + if (this.media) { + this.media.mediaIsFullscreen = mediaIsFullscreen; + } + }); + + /** @TODO Tests for user inactive crud. remove before merging (CJP) */ + // this.media?.addEventListener('pointermove', () => { + // console.log('POINTER MOVING MEDIA'); + // }); + + // this.mediaController?.addEventListener('pointermove', () => { + // console.log('POINTER MOVING MEDIA CONTROLLER'); + // }); } #setupCSSProperties() { @@ -397,7 +436,7 @@ class MuxPlayerElement extends VideoApiElement implements MuxPlayerElement { } connectedCallback() { - const muxVideo = this.shadowRoot?.querySelector('mux-video') as MuxVideoElement; + const muxVideo = this.shadowRoot?.querySelector('mux-video') as unknown as MuxVideoElement; if (muxVideo) { muxVideo.metadata = getMetadataFromAttrs(this); } @@ -1743,6 +1782,37 @@ class MuxPlayerElement extends VideoApiElement implements MuxPlayerElement { } this.setAttribute(PlayerAttributes.NO_TOOLTIPS, ''); } + + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + get adTagUrl() { + return this.media?.adTagUrl ?? this.getAttribute(PlayerAttributes.AD_TAG_URL) ?? undefined; + } + + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + set adTagUrl(val: string | undefined) { + if (val === this.adTagUrl) return; + + if (val) { + this.setAttribute(PlayerAttributes.AD_TAG_URL, val); + } else { + this.removeAttribute(PlayerAttributes.AD_TAG_URL); + } + } + + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + get adBreak() { + return this.media?.adBreak ?? false; + } + + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + get adBreakTotalAds() { + return this.media?.adBreakTotalAds; + } + + /** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ + get adBreakAdPosition() { + return this.media?.adBreakAdPosition; + } } export function getVideoAttribute(el: MuxPlayerElement, name: string) { diff --git a/packages/mux-player/src/media-chrome/ads/media-ad-count-display.ts b/packages/mux-player/src/media-chrome/ads/media-ad-count-display.ts new file mode 100644 index 000000000..83e976e41 --- /dev/null +++ b/packages/mux-player/src/media-chrome/ads/media-ad-count-display.ts @@ -0,0 +1,155 @@ +import { MediaTextDisplay } from 'media-chrome/dist/media-text-display.js'; +import { getNumericAttr, getStringAttr, setNumericAttr, setStringAttr } from 'media-chrome/dist/utils/element-utils.js'; +import { globalThis } from 'media-chrome/dist/utils/server-safe-globals.js'; +import { MediaUIAttributes as MediaUIAttributesBase } from 'media-chrome/dist/constants.js'; +// import { nouns } from 'media-chrome/dist/labels/labels.js'; + +const MediaUIAttributes = { + ...MediaUIAttributesBase, + MEDIA_AD_BREAK_TOTAL_ADS: 'mediaadbreaktotalads', + MEDIA_AD_BREAK_AD_POSITION: 'mediaadbreakadposition', +} as const; + +export const Attributes = { + PREFIX: 'prefix', +}; + +const CombinedAttributes = [ + ...Object.values(Attributes), + MediaUIAttributes.MEDIA_AD_BREAK_TOTAL_ADS, + MediaUIAttributes.MEDIA_AD_BREAK_AD_POSITION, +]; + +// Todo: Use data locals: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString + +const DEFAULT_COUNT_SEP = 'of'; +const DEFAULT_PREFIX = 'Advertisement'; + +const formatLabel = (el: MediaAdCountDisplay, { countSep = DEFAULT_COUNT_SEP } = {}): string => { + const prefixPart = el.prefix ? `${el.prefix}: ` : ''; + return `${prefixPart}${el.mediaAdBreakAdPosition} ${countSep} ${el.mediaAdBreakTotalAds}`; +}; + +// const DEFAULT_MISSING_TIME_PHRASE = 'video not loaded, unknown time.'; + +const updateAriaValueText = (el: MediaAdCountDisplay): void => { + const fullPhrase = formatLabel(el); + el.setAttribute('aria-valuetext', fullPhrase); +}; + +/** + * @attr {string} prefix - the prefix string for the display. 'Advertisement' by default. + * @attr {number} mediaadbreaktotalads - (read-only) total number of ads in the current ad break + * @attr {number} mediaadbreakadposition - (read-only) current ad index playing in the current ad break + */ +class MediaAdCountDisplay extends MediaTextDisplay { + #slot: HTMLSlotElement; + + static get observedAttributes(): string[] { + return [...super.observedAttributes, ...CombinedAttributes, 'disabled']; + } + + constructor() { + super(); + + this.#slot = this.shadowRoot?.querySelector('slot') as HTMLSlotElement; + this.#slot.innerHTML = `${formatLabel(this)}`; + } + + connectedCallback(): void { + if (!this.hasAttribute('disabled')) { + this.enable(); + } + + /** @TODO Implement these */ + this.setAttribute('role', 'progressbar'); + this.setAttribute('aria-label', 'FILL ME IN'); + + super.connectedCallback(); + } + + disconnectedCallback(): void { + this.disable(); + super.disconnectedCallback(); + } + + attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { + if (CombinedAttributes.includes(attrName)) { + this.update(); + } else if (attrName === 'disabled' && newValue !== oldValue) { + if (newValue == null) { + this.enable(); + } else { + this.disable(); + } + } + + super.attributeChangedCallback(attrName, oldValue, newValue); + } + + enable(): void { + this.tabIndex = 0; + } + + disable(): void { + this.tabIndex = -1; + } + + // Own props + + /** + * Describe me + */ + get prefix(): string { + return getStringAttr(this, Attributes.PREFIX, DEFAULT_PREFIX); + } + + set prefix(val: string | undefined) { + /** @TODO inaccurate type def in media chrome. Accepts/expects nullish. (CJP) */ + /** @ts-ignore */ + setStringAttr(this, Attributes.PREFIX, val); + } + + // Props derived from media UI attributes + + /** + * Describe me + */ + get mediaAdBreakTotalAds(): number | undefined { + return getNumericAttr(this, MediaUIAttributes.MEDIA_AD_BREAK_TOTAL_ADS); + } + + set mediaAdBreakTotalAds(val: number | undefined) { + /** @TODO inaccurate type def in media chrome. Accepts/expects nullish. (CJP) */ + /** @ts-ignore */ + setNumericAttr(this, MediaUIAttributes.MEDIA_AD_BREAK_TOTAL_ADS, val); + } + + /** + * Describe me + */ + get mediaAdBreakAdPosition(): number | undefined { + return getNumericAttr(this, MediaUIAttributes.MEDIA_AD_BREAK_AD_POSITION); + } + + set mediaAdBreakAdPosition(val: number | undefined) { + /** @TODO inaccurate type def in media chrome. Accepts/expects nullish. (CJP) */ + /** @ts-ignore */ + setNumericAttr(this, MediaUIAttributes.MEDIA_AD_BREAK_AD_POSITION, val); + } + + update(): void { + const label = formatLabel(this); + updateAriaValueText(this); + // Only update if it changed, timeupdate events are called a few times per second. + if (label !== this.#slot.innerHTML) { + this.#slot.innerHTML = label; + } + } +} + +if (!globalThis.customElements.get('media-ad-count-display')) { + globalThis.customElements.define('media-ad-count-display', MediaAdCountDisplay); +} + +export default MediaAdCountDisplay; diff --git a/packages/mux-player/src/template.ts b/packages/mux-player/src/template.ts index beddf68da..694de23fd 100644 --- a/packages/mux-player/src/template.ts +++ b/packages/mux-player/src/template.ts @@ -2,6 +2,7 @@ import 'media-chrome/dist/media-theme-element.js'; // @ts-ignore import cssStr from './styles.css'; import './dialog'; +import './media-chrome/ads/media-ad-count-display'; import { getStreamTypeFromAttr } from './helpers'; import { html } from './html'; import { i18n, stylePropsToString } from './utils'; @@ -83,6 +84,13 @@ export const partsListStr = Object.values(Parts).join(', '); export const content = (props: MuxTemplateProps) => html` html` cast-receiver="${props.castReceiver ?? false}" drm-token="${props.tokens?.drm ?? false}" exportparts="video" + adtagurl="${/** @TODO Move to separate/extended, ads-only impl/module? (CJP) */ props.adTagUrl ?? false}" > ${props.storyboard ? html`` diff --git a/packages/mux-player/src/themes/gerwig/gerwig.html b/packages/mux-player/src/themes/gerwig/gerwig.html index a17fecb71..624e3c74f 100644 --- a/packages/mux-player/src/themes/gerwig/gerwig.html +++ b/packages/mux-player/src/themes/gerwig/gerwig.html @@ -511,6 +511,20 @@ --bottom-playback-rate-selectmenu: none; --bottom-pip-button: none; } + + /** + * @TODO This is needed because media-container disables pointer-events on gestures-layer by default (CJP) + * @TODO This means the ad container is no longer clickable. Likely needed for mobile anyway, but this means + * Click div needs to be implemented for ad click through (CJP) + * @TODO This solution is mutually exclusive from use of Google IMA baked in skip ads UI (CJP) + */ + /* media-controller::part(gesture-layer) { + pointer-events: auto; + } + + media-controller::part(media-layer) { + pointer-events: none; + } */ + + Unmute --> -