import { createMediaTracker } from '@financial-times/media-tracking-sdk' import YoutubeMediaTrackerAdapter from './trackingAdapter' declare global { interface Window { onYouTubeIframeAPIReady?: () => void } } interface Opts { rootContentId?: string assetType?: string } const unloadEventName = 'onbeforeunload' in window ? 'beforeunload' : 'unload' class YouTubeClip { player: YT.Player | null = null readonly playerPlaceholder: HTMLElement readonly videoId: string | null private lastQuarter = false private loops = 0 private progressInterval: number | null = null private mediaTracker: ReturnType | null = null private fireUnloadEvent: () => void private opts: Opts constructor(placeholder: HTMLElement, opts: Opts = {}) { this.playerPlaceholder = placeholder this.videoId = this.playerPlaceholder.getAttribute('data-youtube-id') this.fireUnloadEvent = this.unload.bind(this) this.opts = opts if (!this.videoId) { return } this.player = new YT.Player(this.playerPlaceholder, { videoId: this.videoId, host: 'https://www.youtube-nocookie.com', events: { onStateChange: this.onPlayerStateChange.bind(this), }, }) this.mediaTracker = createMediaTracker({ mediaType: 'video', adapter: new YoutubeMediaTrackerAdapter( this.playerPlaceholder as HTMLMediaElement, this.player, () => this.loops ), 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, }), }) this.init() } init() { window.addEventListener(unloadEventName, this.fireUnloadEvent, { capture: true, }) this.mediaTracker?.mount() } unload() { this.destroy() } /** * onPlayerStateChange * Handles events fired by Youtube based on user interactions */ private onPlayerStateChange(event: YT.OnStateChangeEvent) { const state = event?.data switch (state) { case YT.PlayerState.BUFFERING: this.fireEvent('waiting') break case YT.PlayerState.PLAYING: this.fireEvent('playing') this.startTimeUpdateTracking() break case YT.PlayerState.PAUSED: this.fireEvent('pause') this.stopTimeUpdateTracking() break case YT.PlayerState.ENDED: this.fireEvent('ended') this.stopTimeUpdateTracking() // Reset for replay break default: break } } /** * startTimeUpdateTracking * Starts an interval timer to repeatedly check the user's progress whilst the video is playing * */ private startTimeUpdateTracking() { this.stopTimeUpdateTracking() this.progressInterval = window.setInterval(() => { if (!this.player) return this.checkLoop() this.fireEvent('timeupdate') }, 250) // Check progress 4 times a second } /** * stopTimeUpdateTracking * Stop the interval that is used for time update tracking */ private stopTimeUpdateTracking() { if (this.progressInterval !== null) { clearInterval(this.progressInterval) this.progressInterval = null } } /** * fireEvent * Dispatch a custom event with tracking data */ private async fireEvent(action: string) { this.playerPlaceholder.dispatchEvent(new Event(action)) } // Check if the video has made a loop. private checkLoop(progress: null | number = null) { if (progress === null) { progress = this.mediaTracker?.getProgress() ?? 0 } // If the video is in the last quarter, set lastQuarter to true. if (progress >= 75) { this.lastQuarter = true } // If it then returns to the first quarter, count a loop. else if (progress <= 25 && this.lastQuarter) { this.lastQuarter = false this.loops++ } } /** * destroy * A tear down function */ destroy(): void { window.removeEventListener(unloadEventName, this.fireUnloadEvent, { capture: true, }) this.stopTimeUpdateTracking() this.mediaTracker?.flushWatched() this.mediaTracker?.unmount() try { this.player?.destroy() } catch { throw new Error('Failed to destroy YouTube player instance.') } } static init(rootElement: Document | HTMLElement = document, opts: Opts = {}) { const youtubePlaceholders = rootElement.querySelectorAll( '[data-component="youtube-video"]' ) const instances: YouTubeClip[] = [] if (youtubePlaceholders.length > 0) { if (window.YT && window.YT.Player) { youtubePlaceholders.forEach((placeholder) => { instances.push(new YouTubeClip(placeholder as HTMLElement, opts)) }) } else { window.onYouTubeIframeAPIReady = () => { youtubePlaceholders.forEach((placeholder) => { instances.push(new YouTubeClip(placeholder as HTMLElement, opts)) }) } const script = document.createElement('script') script.src = 'https://www.youtube.com/iframe_api' document.head.appendChild(script) } } return instances } } export default YouTubeClip