import { formatTime, throttle, isMobile, isElementVisible } from './utils' import ProgressBar from './progressBar' import { createMediaTracker } from '@financial-times/media-tracking-sdk' import ClipMediaTrackerAdapter from './trackingAdapter' type ExpanderType = { oExpanderElement: HTMLElement | null init(rootEl: HTMLElement, opts?: Opts): ExpanderType[] | ExpanderType expander: ClipInterface[] | ClipInterface expand(isSilent?: boolean): void collapse(isSilent?: boolean): void } let Expander: ExpanderType | null = null const loadExpander = async () => { Expander = (await import('@financial-times/o-expander/main.js')).default } interface Opts { autorender?: boolean autoplay?: boolean | string id?: string loop?: boolean | string layout?: string mute?: boolean | string noAudio?: boolean | string noDescription?: boolean | string noInfoBox?: boolean | string noCaption?: boolean | string fadeOutDelay?: number progressBarTimeTriggerTap?: number progressBarTapDelay?: number progressTimeStepSeconds?: number intersectionObserverThreshold?: number closedCaption?: boolean caption?: string systemTitle?: string autoShowClosedCaptions?: boolean rootContentId?: string assetType?: string } // converts data-cp-clip attributes to an options object function getOptionsFromDataAttributes(attributes: NamedNodeMap) { const opts: Opts = {} // Try to get config set declaratively on the element Array.prototype.forEach.call(attributes, (attr) => { if (attr.name.indexOf('data-cp-clip') === 0) { // Remove the prefix part of the data attribute name and hyphen-case to camelCase const key = attr.name .replace('data-cp-clip-', '') .replace(/-([a-z])/g, (w: string) => { // no-audio will become noAudio return w.toUpperCase().replace('-', '') }) .replace(/-/g, '') if (attr.value === 'true' || attr.value === 'false') { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-extra-semi ;(opts as any)[key] = attr.value === 'true' } else { opts[key as keyof Opts] = attr.value } } }) return opts } const unloadEventName = 'onbeforeunload' in window ? 'beforeunload' : 'unload' const defaultOpts = { autorender: true, fadeOutDelay: 2000, progressBarTimeTriggerTap: 0, progressBarTapDelay: 4000, progressTimeStepSeconds: 5, autoplay: false, loop: false, intersectionObserverThreshold: 0, autoShowClosedCaptions: true, } //Clip and ClipFallbackImage must follow this interface due this methods are called on implementations class ClipInterface { constructor(protected el: HTMLElement, protected opts: Opts) {} // eslint-disable-next-line @typescript-eslint/no-empty-function init() {} // eslint-disable-next-line @typescript-eslint/no-empty-function unload() {} // eslint-disable-next-line @typescript-eslint/no-empty-function destroy() {} } class Clip extends ClipInterface { public containerEl: HTMLElement public videoEl: HTMLVideoElement // amount of the video, in milliseconds, that has actually been 'watched' public loops = 0 public canAutoplay = false public useCustomPlayer = true public started = false //variable to know when the video started to play , use to track amount of time played public opts: Opts private isInViewPort = false //If we don't store the function that is set as a listener on a window event we can't removed at destroy event at the end private fireUnloadEvent: () => void private onResizeEvent: () => void private onClickOutsideEvent: (e: MouseEvent) => void private onOfflineEvent: () => void private onOnlineEvent: () => void private onKeyFocusEvent: (e: KeyboardEvent) => void // Observer to detect when the video is visible private observer: IntersectionObserver //flag to know if the video progress was on last quarter to help track loops private lastQuarter = false //track video progress dispatched public autoplayPaused = false private expander: ExpanderType | null = null private offlineMessageEl: HTMLElement | null = null private progressRing: HTMLElement | null = null private lastTimeUpdate: number | null = null private muteIcon: HTMLElement | null = null private controls: HTMLElement | null = null private playPauseButton: HTMLElement | null = null private bottomBar: HTMLElement | null = null private playText: HTMLElement | null = null private loadingText: HTMLElement | null = null private progressBar: ProgressBar | null = null private isMobileDevice = false private loadingDebounce: ReturnType | null = null private mediaTracker: ReturnType constructor(el: HTMLElement, opts: Opts) { super(el, opts) this.containerEl = el this.videoEl = el.querySelector('video') this.isMobileDevice = isMobile() this.fireUnloadEvent = this.unload.bind(this) this.onClickOutsideEvent = this.onClickOutside.bind(this) this.onResizeEvent = this.resizeControls.bind(this) this.onOfflineEvent = this.showOffLineMessage.bind(this) this.onOnlineEvent = this.hideOffLineMessage.bind(this) this.onKeyFocusEvent = this.onKeyFocus.bind(this) this.createOfflineMessage() this.opts = Object.assign( {}, defaultOpts, opts, getOptionsFromDataAttributes(this.containerEl.attributes) ) this.observer = new IntersectionObserver( this.visibilityListener.bind(this), { threshold: this.opts.intersectionObserverThreshold, } ) this.observer.observe(this.containerEl) this.mediaTracker = createMediaTracker({ mediaType: 'video', adapter: new ClipMediaTrackerAdapter(this.videoEl, this), customComponentEvents: ['cc-default', 'click:out', 'cta:click', 'view'], getContext: () => ({ url: window.location.href, referrer: document.referrer, rootContentId: this.opts.rootContentId, content: this.opts.assetType ? { uuid: this.opts.rootContentId, asset_type: this.opts.assetType, } : undefined, }), }) // if data-cp-clip-js attribute already exists then don't add tracking events again if (this.containerEl.hasAttribute('data-cp-clip-js')) { return } else { if (this.opts.autorender === true) { this.init() this.containerEl.setAttribute('data-cp-clip-js', '') } } } get muted() { return this.videoEl.muted } set muted(value: boolean) { this.videoEl.muted = value if (this.muteIcon) { this.muteIcon.setAttribute('data-mute', String(value)) this.muteIcon.setAttribute('aria-label', value ? 'Unmute' : 'Mute') } } unload() { this.destroy() } async init() { try { this.performSourceErrorCheck() if ( !this.opts.noDescription || !this.opts.noInfoBox || !this.opts.noCaption ) { await loadExpander() } this.mediaTracker.mount() this.videoEl.addEventListener( 'timeupdate', this.handleTimeUpdate.bind(this) ) /* When a video starts to autoplay the controls will be visible for about a second. We want to remove the controls for the initial play, but also add them back in when the user wants to see the controls (mouseover focus, and press on a touchscreen). */ if (this.opts.autoplay && !this.useCustomPlayer) { this.videoEl.addEventListener('mouseenter', () => { this.videoEl.setAttribute('controls', '') }) this.videoEl.addEventListener('touchenter', () => { this.videoEl.setAttribute('controls', '') }) this.videoEl.addEventListener('focus', () => { this.videoEl.setAttribute('controls', '') }) } // send 'watched' event on page unload, window.addEventListener(unloadEventName, this.fireUnloadEvent, { capture: true, }) const expander = Expander?.init(this.containerEl) this.expander = (Array.isArray(expander) ? expander[0] : expander) ?? null const oExpanderElement = this.expander?.oExpanderElement if (oExpanderElement) { const buttonElement = oExpanderElement.querySelector('button') if (buttonElement) { buttonElement.setAttribute('tabindex', '-1') } } // Listen to offline/online events window.addEventListener('offline', this.onOfflineEvent) window.addEventListener('online', this.onOnlineEvent) // Listen to stalled event to show offline message this.videoEl.addEventListener('stalled', this.onOfflineEvent) // If the user play a buffered video offline, we need to hide the offline message. this.videoEl.addEventListener('playing', this.onOfflineEvent) // Mobile App, differently from DotCom, can render article offline, so we need to check if we are already offline. if (!window.navigator.onLine && this.expander) { this.showOffLineMessage() } if (this.expander) { this.descriptionToggle() } //Add custom player if (this.useCustomPlayer) { this.createCustomPlayer() //set default value for mute this.muted = !this.muteIcon || // TODO: fix this type properly (this.opts.autoplay as boolean) } } catch (error) { const customEvent = new CustomEvent( 'cpContentPipeline.clipComponent.initFailure', { detail: { clipInstance: this, error }, bubbles: true, } ) this.containerEl.dispatchEvent(customEvent) } } // method to check if the video has made a loop checkLoop(progress: null | number = null) { if (progress === null) { progress = this.getProgress() } //if the progress video is in the last quarter we set flag lastQuarter to true if (progress >= 75) { this.lastQuarter = true } //if the progress video is in the first quarter and the flag lastQuarter is true we increment the loopÄ else if (progress <= 25 && this.lastQuarter) { this.lastQuarter = false this.loops++ } } //function to check if containerEl is still in the DOM checkIfElementInDOM() { if ( this.containerEl && this.containerEl.ownerDocument.contains(this.containerEl) ) { return true } return false } fireEvent(action: string, extraDetail = {}) { const event = new CustomEvent(action, { detail: extraDetail, }) this.videoEl.dispatchEvent(event) } getProgress(): number { return this.mediaTracker.getProgress() } destroy() { // remove listeners this.progressBar?.destroy() window.removeEventListener(unloadEventName, this.fireUnloadEvent, { capture: true, }) window.removeEventListener('resize', this.onResizeEvent) window.removeEventListener('click', this.onClickOutsideEvent) window.removeEventListener('offline', this.onOfflineEvent) window.removeEventListener('online', this.onOnlineEvent) window.removeEventListener('keydown', this.onKeyFocusEvent) this.observer.unobserve(this.containerEl) this.observer.disconnect() this.mediaTracker.flushWatched() this.mediaTracker.unmount() } isVideoPlaying() { return ( !this.videoEl.paused && !this.videoEl.ended && this.videoEl.readyState > 2 ) } createCustomPlayer() { const video = this.videoEl // disable default controls video.controls = false // CREATE ALL THE CUSTOM CONTOLS const customControls = document.createElement('div') customControls.classList.add('cp-clip__video-controls') this.controls = customControls const bottomBar = document.createElement('div') bottomBar.classList.add('cp-clip__video-controls-bottom-bar') this.bottomBar = bottomBar // fade out the bottom bar after a short display this.bottomBar.classList.add('cp-clip__video-controls-bottom-bar--fade-out') const playIconsAriaLabel = this.opts?.caption ? `Play video: ${this.opts.caption}` : 'Play video' const playIcon = document.createElement('button') if (this.videoEl.id) { playIcon.setAttribute('aria-controls', this.videoEl.id) } playIcon.classList.add('cp-clip__play-icon') playIcon.setAttribute('aria-label', playIconsAriaLabel) playIcon.setAttribute('data-trackable', 'video-clip-play') playIcon.setAttribute('tabindex', '-1') playIcon.onclick = () => video.play() const replayIcon = document.createElement('button') if (this.videoEl.id) { replayIcon.setAttribute('aria-controls', this.videoEl.id) } replayIcon.classList.add('cp-clip__replay-icon') replayIcon.setAttribute('data-trackable', 'video-clip-replay') replayIcon.setAttribute('tabindex', '-1') replayIcon.setAttribute('aria-label', playIconsAriaLabel) replayIcon.onclick = () => { video.play() replayIcon.replaceWith(playIcon) } const playIconContainer = document.createElement('div') playIconContainer.classList.add('cp-clip__play-icon-container') playIconContainer.setAttribute('data-o3-theme', 'inverse') playIconContainer.append(playIcon) const loadingIndicator = document.createElement('div') loadingIndicator.classList.add( 'o-loading', 'o-loading--light', 'o-loading--large' ) const loadingIconContainer = document.createElement('div') loadingIconContainer.classList.add('cp-clip__loading-icon-container') loadingIconContainer.setAttribute('data-o3-theme', 'inverse') const loadingIconBg = document.createElement('div') loadingIconBg.classList.add('cp-clip__loading-icon-bg') loadingIconContainer.append(loadingIconBg) loadingIconBg.append(loadingIndicator) const loadingText = document.createElement('span') loadingText.innerText = 'LOADING' loadingText.classList.add('cp-clip__loading-text', 'o3-type-label') loadingIconContainer.append(loadingText) this.loadingText = loadingText customControls.append(loadingIconContainer) const muteIcon = document.createElement('button') if (this.videoEl.id) { muteIcon.setAttribute('aria-controls', this.videoEl.id) } muteIcon.classList.add('cp-clip__mute-icon') muteIcon.setAttribute('data-mute', String(this.muted)) muteIcon.setAttribute('data-test-id', 'cp-clip__mute-icon') muteIcon.setAttribute('data-trackable', 'toggle-mute-video') muteIcon.setAttribute('aria-label', this.muted ? 'Unmute' : 'Mute') this.muteIcon = muteIcon this.muteIcon.addEventListener('click', () => { this.muted = !this.muted }) // this will ensure that the controls can fade out for mouse-based users this.muteIcon.onmouseup = () => { this.muteIcon?.blur() } const playPauseButton = document.createElement('button') playPauseButton.setAttribute('type', 'button') if (this.videoEl.id) { playPauseButton.setAttribute('aria-controls', this.videoEl.id) } this.playPauseButton = playPauseButton this.togglePlayPauseButton() if (this.opts.loop) { playPauseButton.classList.add('cp-clip__playpause-icon-autoplay') customControls.classList.add('cp-clip__video-mode-autoplay') const progressRing = document.createElement('div') progressRing.classList.add('cp-clip__playpause-icon-autoplay-progress') playPauseButton.appendChild(progressRing) this.progressRing = progressRing } else { playPauseButton.classList.add('cp-clip__playpause-icon') this.progressBar = this.createProgressBar() // if we have a progress bar then we have the ability to step back and forward, so permit focussing on the video video.setAttribute('tabIndex', '0') video.setAttribute( 'aria-label', 'Video clip: use left and right arrow keys to skip backward or forwards' ) } playPauseButton.onclick = () => { this.togglePlay() } // this will ensure that the controls can fade out for mouse-based users playPauseButton.onmouseup = () => { playPauseButton.blur() } const noAudioIcon = this.createNoAudioIcon() if (this.opts.closedCaption) { const closedCaptionIcon = this.createClosedCaptionIcon() const toggleClosedCaption = () => { this.toggleCaptions() closedCaptionIcon.toggleAttribute('data-display-closed-captions') } if (this.opts.autoplay) { toggleClosedCaption() } closedCaptionIcon.onclick = () => { toggleClosedCaption() const captionsOn = closedCaptionIcon.hasAttribute( 'data-display-closed-captions' ) this.fireEvent('cta:click', { trigger_action: `turn captions ${captionsOn ? 'on' : 'off'}`, }) } bottomBar.append(closedCaptionIcon) } bottomBar.append(playPauseButton) bottomBar.append(this.videoHasNoAudio() ? noAudioIcon : muteIcon) customControls.prepend(playIconContainer) customControls.append(bottomBar) // END CREATE ALL THE CUSTOM CONTOLS video.addEventListener('playing', () => { //resize controls in case poster is different size than video at the end this.resizeControls() this.togglePlayPauseButton() if (this.opts.autoShowClosedCaptions) { this.showCaptionsByDefault() } this.containerEl.classList.remove( 'cp-clip--paused', 'cp-clip--loading', 'cp-clip--ready' ) this.containerEl.classList.add('cp-clip--playing') this.fadeOut() }) video.addEventListener('pause', () => { this.togglePlayPauseButton() this.containerEl.classList.remove( 'cp-clip--playing', 'cp-clip--loading', 'cp-clip--ready' ) this.containerEl.classList.add('cp-clip--paused') playIcon.setAttribute('data-trackable', 'video-clip-resume') this.fadeIn() if (this.playText?.innerText) { this.playText.innerText = 'RESUME' } }) video.addEventListener('seeked', () => { if (!this.videoEl.ended && this.videoEl.paused) { replayIcon.replaceWith(playIcon) if (this.playText?.innerText) { this.playText.innerText = 'RESUME' } } }) video.addEventListener('ended', () => { playIcon.replaceWith(replayIcon) playIcon.setAttribute('data-trackable', 'video-clip-replay') if (this.playText?.innerText) { this.playText.innerText = 'REPLAY' } }) this.resizeControls() // EVENTS TO DO WITH READY STATE / LOADING STATUS const onLoadedMetaData = () => { this.resizeControls() const duration = formatTime( this.videoEl && this.videoEl.duration ? this.videoEl.duration : 0 ) this.playText = document.createElement('span') this.playText.innerText = `PLAY | ${duration}` this.playText.classList.add('cp-clip__play-text', 'o3-type-label') playIconContainer.append(this.playText) this.containerEl.classList.add('cp-clip--ready') } const onLoadedData = () => { const hasNoAudio = this.videoHasNoAudio() if (hasNoAudio) { this.muteIcon?.replaceWith(noAudioIcon) } } if (video.readyState >= 1) { onLoadedMetaData() } else { video.addEventListener('loadedmetadata', onLoadedMetaData) } if (video.readyState >= 2) { onLoadedData() } else { video.addEventListener('loadeddata', () => { onLoadedData() }) } if (video.readyState >= 3) { this.hideLoadingIndicator() } // NB: videos can go back and forth between readystates 2 and 3 // will fire when video drops from readystate >=3 to readystate <=2 video.addEventListener('waiting', () => { // ci-3375 debounce loading ui to prevent flash of content on loop restart // loop restart acts as a seeking event, which can trigger waiting event // we should not allow the loading ui to flash up if waiting time is only a few milliseconds this.loadingDebounce = setTimeout(() => { this.showLoadingIndicator() }, 10) }) // will fire when video goes from readystate <=2 to 3 video.addEventListener('canplay', () => { this.hideLoadingIndicator() }) // will fire when video gets to readystate 4 video.addEventListener('canplaythrough', () => { this.hideLoadingIndicator() }) // END EVENTS TO DO WITH READY STATE / LOADING STATUS customControls.addEventListener('click', (event) => { const isVisible = isElementVisible(customControls) this.fadeIn() if (event.target === customControls && isVisible) { this.togglePlay() } }) this.videoEl.addEventListener('blur', () => this.videoEl.classList.remove('no-focus-style') ) const videoContainer = this.containerEl.querySelector( '.cp-clip__video-container' ) const showAndHideControls = () => { if (!this.videoEl.paused) { this.fadeIn() return this.fadeOut() } } if (!this.isMobileDevice) { customControls?.addEventListener( 'mousemove', throttle(showAndHideControls, 250) ) } window.addEventListener('keydown', this.onKeyFocusEvent) window.addEventListener('resize', this.onResizeEvent) window.addEventListener('click', this.onClickOutsideEvent) const videoWorks = Boolean(video.canPlayType) if (videoWorks) { video.controls = false customControls.classList.remove('hidden') videoContainer?.append(customControls) if (this.progressBar) { videoContainer?.append(this.progressBar.getContainer()) } } const event = new CustomEvent( 'cpContentPipeline.clipComponent.customPlayerCreated', { bubbles: true, detail: { clipId: this.opts.id } } ) this.videoEl.dispatchEvent(event) } createOfflineMessage() { this.offlineMessageEl = document.createElement('div') this.offlineMessageEl.classList.add('cp-clip__offline-message') this.offlineMessageEl.innerHTML = 'Your device appears to be offline. Reconnect to the internet to view.' } showOffLineMessage() { if (!window.navigator.onLine) { const oExpanderElement = this.expander?.oExpanderElement const videoInfoElement = oExpanderElement?.querySelector('.video-info') if (videoInfoElement) { videoInfoElement.prepend( this.offlineMessageEl ?? document.createElement('div') ) } const buttonElement = oExpanderElement?.querySelector('button') if (buttonElement) { buttonElement.classList.remove('o-expander__toggle-empty') buttonElement.setAttribute('tabindex', '0') } this.expander?.expand() this.showDescription() } } hideLoadingIndicator() { if (this.loadingDebounce !== null) { clearTimeout(this.loadingDebounce) } this.loadingDebounce = null this.containerEl.classList.remove('cp-clip--loading') this.fadeOut(true) } showLoadingIndicator() { this.containerEl.classList.add('cp-clip--loading') this.fadeIn() } hideOffLineMessage() { const oExpanderElement = this.expander?.oExpanderElement const expanderContent = oExpanderElement?.querySelector( '.o-expander__content' ) const offlineMessageElement = expanderContent?.querySelector( '.cp-clip__offline-message' ) if (offlineMessageElement) { expanderContent?.removeChild(offlineMessageElement) } this.expander?.collapse() const buttonElement = oExpanderElement?.querySelector('button') if (buttonElement) { buttonElement.setAttribute('tabindex', '-1') buttonElement.classList.add('o-expander__toggle-empty') } } createNoAudioIcon() { const noAudioIcon = document.createElement('span') noAudioIcon.classList.add('cp-clip__no-audio') noAudioIcon.setAttribute('role', 'img') noAudioIcon.setAttribute('aria-label', 'This clip has no audio') return noAudioIcon } createClosedCaptionIcon() { const closedCaptionIcon = document.createElement('button') closedCaptionIcon.classList.add(`cp-clip__closed-caption`) closedCaptionIcon.setAttribute('aria-label', 'Closed Caption') return closedCaptionIcon } showCaptionsByDefault() { const closedCaptionIcon = this.containerEl.querySelector( '.cp-clip__closed-caption' ) closedCaptionIcon?.setAttribute('data-display-closed-captions', '') const tracks = this.videoEl.textTracks if (tracks.length) { for (const track of tracks) { if (track.language === 'en') { track.mode = 'showing' } } } this.fireEvent('cc-default') } toggleCaptions = () => { const tracks = this.videoEl.textTracks if (tracks.length) { for (const track of tracks) { if (track.language === 'en') { track.mode = track.mode === 'showing' ? 'hidden' : 'showing' } } } } visibilityListener([observed]: IntersectionObserverEntry[]) { // The video is in viewport this.isInViewPort = Boolean(observed && observed.isIntersecting) if (this.isInViewPort) { if (this.opts.autoplay) { this.videoEl.controls = false //if the video was paused and hasnt started or was launch autoplay before without stopping it if ( this.videoEl.paused && (!this.started || this.canAutoplay === true) ) { this.autoplayPaused = false this.canAutoplay = true const playPromise = this.videoEl.play() if (playPromise !== undefined) { playPromise.catch(() => { // Auto-play was prevented // Show paused UI. }) } } } this.fireEvent('view') } else { this.videoEl.pause() if (this.canAutoplay) { this.autoplayPaused = true } } } handleTimeUpdate(ev: Event) { const eventType = ev.type if (eventType === 'timeupdate') { const progress = this.getProgress() this.checkLoop(progress) if (this.progressRing) { const now = Date.now() const durationAnimation = !this.lastTimeUpdate ? 250 : now - this.lastTimeUpdate this.lastTimeUpdate = now this.progressRing.style.transition = `clip-path ${durationAnimation}ms linear` this.progressRing.style.clipPath = this.getClipPathStringFromProgress(progress) } } } fadeIn() { this.bottomBar?.classList.remove( 'cp-clip__video-controls-bottom-bar--fade-out' ) } fadeOut(immediate = false) { if (this.videoEl.paused) { return } const applyFadeOut = () => { this.bottomBar?.classList.add( 'cp-clip__video-controls-bottom-bar--fade-out' ) } if (immediate) { applyFadeOut() } else { return window.setTimeout(applyFadeOut, this.opts.fadeOutDelay) } } getClipPathStringFromProgress(percentage: number) { // Ensure the percentage is within the range [0, 100] const clampedPercentage = Math.max(0, Math.min(100, percentage)) //extrapolate a value between 0 - 12.5 to a value from 0 - 50 function extrapolateValue(value: number) { // Ensure the value is within the range [0, 12.5] const clampedValue = Math.max(0, Math.min(12.5, value)) // Calculate the extrapolated value in the range [0, 50] const extrapolatedValue = (clampedValue / 12.5) * 50 return extrapolatedValue } let points //calculate clip-path every 12.5 percentage if (clampedPercentage <= 12.5) { const extrap = extrapolateValue(clampedPercentage) + 50 points = `50% 0%, ${extrap}% 0%, 50% 50%` } else if (clampedPercentage <= 25) { const extrap = extrapolateValue(clampedPercentage - 12.5) points = `50% 0%, 100% 0%, 100% ${extrap}%, 50% 50%` } else if (clampedPercentage <= 37.5) { const extrap = extrapolateValue(clampedPercentage - 25) + 50 points = `50% 0%, 100% 0%, 100% ${extrap}%,50% 50%` } else if (clampedPercentage <= 50) { const extrap = 100 - extrapolateValue(clampedPercentage - 37.5) points = `50% 0%, 100% 0%, 100% 100%, ${extrap}% 100% ,50% 50%` } else if (clampedPercentage <= 62.5) { const extrap = 50 - extrapolateValue(clampedPercentage - 50) points = `50% 0%, 100% 0%, 100% 100%, ${extrap}% 100% ,50% 50%` } else if (clampedPercentage <= 75) { const extrap = 100 - extrapolateValue(clampedPercentage - 62.5) points = `50% 0%, 100% 0%, 100% 100%, 0% 100% , 0% ${extrap}%, 50% 50%` } else if (clampedPercentage <= 87.5) { const extrap = 50 - extrapolateValue(clampedPercentage - 75) points = `50% 0%, 100% 0%, 100% 100%, 0% 100% , 0% ${extrap}%, 50% 50%` } else { const extrap = extrapolateValue(clampedPercentage - 87.5) points = `50% 0%, 100% 0%, 100% 100%, 0% 100% , 0% 0%, ${extrap}% 0%, 50% 50%` } // Create the clip-path string const clipPathString = `polygon(${points})` return clipPathString } videoHasNoAudio() { // Use option if set - false is a valid value for this.opts.noAudio return typeof this.opts.noAudio !== undefined ? this.opts.noAudio : // Assuming video has audio by default false } descriptionToggle() { const setActiveTab = (activeTab: string, button: Element | null) => { const inactiveTab = activeTab === 'description' ? 'transcript' : 'description' descriptionButton?.classList.remove('active') transcriptButton?.classList.remove('active') button?.classList.add('active') const activeTabElement = this.containerEl.querySelector( `#${activeTab}-tab` ) as HTMLElement | null if (activeTabElement) { activeTabElement.style.display = 'block' } const inactiveTabElement = this.containerEl.querySelector( `#${inactiveTab}-tab` ) as HTMLElement | null if (inactiveTabElement) { inactiveTabElement.style.display = 'none' } } const videoMetaInfoCaption = this.containerEl.querySelector( '[data-cp-clip-video-meta-info] > [data-cp-clip-caption]' ) const videoMetaInfoExpander = this.containerEl.querySelector( '[data-cp-clip-video-meta-info] > [data-o-component="o-expander"]' ) if (videoMetaInfoExpander && videoMetaInfoCaption) { videoMetaInfoExpander.addEventListener('oExpander.expand', () => { this.fireEvent('cta:click') videoMetaInfoCaption.classList.add('o-normalise-visually-hidden') }) videoMetaInfoExpander.addEventListener('oExpander.collapse', () => { videoMetaInfoCaption.classList.remove('o-normalise-visually-hidden') }) } const descriptionButton = this.containerEl.querySelector( '#description-button' ) as Element | null descriptionButton?.addEventListener('click', () => setActiveTab('description', descriptionButton) ) descriptionButton?.setAttribute('data-trackable', 'description: true') const transcriptButton = this.containerEl.querySelector( '#transcript-button' ) as Element | null transcriptButton?.addEventListener('click', () => setActiveTab('transcript', transcriptButton) ) transcriptButton?.setAttribute('data-trackable', 'transcript: true') } showDescription() { const videoDescriptionExpander = this.containerEl.querySelector( '[data-cp-clip-video-meta-info] > [data-o-component="o-expander"]' ) if (videoDescriptionExpander instanceof HTMLElement) { const expanders = Expander?.init(videoDescriptionExpander) if (Array.isArray(expanders)) { expanders.forEach((expander) => expander.expand()) } else { expanders?.expand() } } } onClickOutside(event: MouseEvent) { if ( !this.containerEl.contains(event.target as Node) && !this.bottomBar?.classList.contains( 'cp-clip__video-controls-bottom-bar--fade-out' ) && !this.videoEl.paused ) { this.fadeOut(true) this.fireEvent('click:out') } } onKeyFocus = (event: KeyboardEvent) => { const hasFocus = this.isFocused() const isPlaying = this.isVideoPlaying() if (!hasFocus) return if (isPlaying && event.key === 'ArrowLeft') { this.progressBar?.stepBackward() event.preventDefault() event.stopImmediatePropagation() } else if (isPlaying && event.key === 'ArrowRight') { this.progressBar?.stepForward() event.preventDefault() event.stopImmediatePropagation() } } resizeControls() { this.isMobileDevice = isMobile() } isFocused() { return ( document.activeElement === this.containerEl || this.containerEl.contains(document.activeElement) ) } togglePlay() { if (this.isVideoPlaying()) { this.videoEl.pause() } else { this.videoEl.play() } this.videoEl.classList.add('no-focus-style') } togglePlayPauseButton() { if (!this.playPauseButton) return const videoIdAriaLabel = this.opts?.caption ? this.opts.caption : 'video' const isPlaying = this.isVideoPlaying() this.playPauseButton.classList.toggle( 'cp-clip__playpause-icon-pause', isPlaying ) this.playPauseButton.classList.toggle( 'cp-clip__playpause-icon-play', !isPlaying ) this.playPauseButton.setAttribute( 'aria-label', isPlaying ? `Pause video: ${videoIdAriaLabel}` : `Play video: ${videoIdAriaLabel}` ) this.playPauseButton.setAttribute( 'data-trackable', isPlaying ? 'video-clip-pause' : 'video-clip-play' ) } createProgressBar(): ProgressBar { return new ProgressBar(this.videoEl, this.opts) } performSourceErrorCheck = () => { const sources = this.videoEl.querySelectorAll('source') const errorMap = new Map() sources?.forEach((source) => { source?.addEventListener('error', () => { const sourceId = source.id errorMap.set(sourceId, true) if (errorMap.size === sources.length) { const customEvent = new CustomEvent( 'cpContentPipeline.clipComponent.loadingFailure', { detail: { clipInstance: this }, bubbles: true, } ) this.containerEl.dispatchEvent(customEvent) } }) }) } static init( rootElParam: HTMLElement | string | null = null, config: Opts = {} ) { const videos: Array = [] let rootEl: HTMLElement | null if (typeof rootElParam === 'string') { rootEl = document.querySelector(rootElParam) } else { rootEl = rootElParam ? rootElParam : document.body } if (!rootEl) return [] const videoEls = rootEl.querySelectorAll( ':not([data-cp-clip-js])[data-o-component~="cp-clip"]' ) for (let i = 0; i < videoEls.length; i++) { const component: ClipInterface = new Clip( videoEls[i], config ) videos.push(component) } return videos } } export default Clip