From 90bbbaafc4fc95f227b3922d156e0fd9103f63f9 Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Thu, 17 Jun 2021 11:17:36 +0200 Subject: [PATCH] feat(analytics): implement OTT video analytics --- .commitlintrc.js | 1 + README.md | 1 + public/index.html | 1 + public/jwpltx.js | 176 +++++++++++++++++++++++++++++++ src/containers/Cinema/Cinema.tsx | 5 +- src/hooks/useOttAnalytics.ts | 55 ++++++++++ types/global.d.ts | 1 + types/jwpltx.d.ts | 6 ++ 8 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 public/jwpltx.js create mode 100644 src/hooks/useOttAnalytics.ts create mode 100644 types/jwpltx.d.ts diff --git a/.commitlintrc.js b/.commitlintrc.js index 3fadfe1ba..0e974a609 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -10,6 +10,7 @@ module.exports = { 'search', 'watchhistory', 'favorites', + 'analytics', ], ], }, diff --git a/README.md b/README.md index ebb28734a..02c8713f4 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ The allowed scopes are: - search - watchhistory - favorites +- analytics ### Subject diff --git a/public/index.html b/public/index.html index bd5588d7d..76c9efcec 100644 --- a/public/index.html +++ b/public/index.html @@ -50,5 +50,6 @@ } + diff --git a/public/jwpltx.js b/public/jwpltx.js new file mode 100644 index 000000000..5c6876a69 --- /dev/null +++ b/public/jwpltx.js @@ -0,0 +1,176 @@ +/*** + Javascript library for sending OTT analytics to JW Player. + Include in your head and include the following listeners: + + jwplayer().on("ready",function(evt) { + jwpltx.ready( + CONFIG.analyticsToken, // Analytics token + window.location.hostname, // Domain name + getParam("fed"), // ID of referring feed + item.mediaid, // ID of media playing + item.title // Title of media playing + ); +}); + + jwplayer().on("time",function(evt) { + jwpltx.time(evt.currentTime,evt.duration); +}); + + jwplayer().on("complete",function(evt) { + jwpltx.complete(); +}); + + jwplayer().on("adImpression",function(evt) { + jwpltx.adImpression(); +}); + ***/ + + + +window.jwpltx = window.jwpltx || {}; + +(function(o) { + // Hostname for sending analytics + const URL = "https://ihe.jwpltx.com/v1/jwplayer6/ping.gif?"; + // Query parameters for sending analytics + const URI = { + "pss": "1", + "oos": "Web", + "oosv": "5", + "sdk": "0" + }; + // query params instance. + let uri; + // Current time for live streams. + let current; + + + + // Process a player ready event + o.ready = function(aid, bun, fed, id, t) { + uri = JSON.parse(JSON.stringify(URI)); + uri.aid = aid; + uri.bun = bun; + uri.fed = fed; + uri.id = id; + uri.t = t; + + uri.emi = generateId(12); + uri.pli = generateId(12); + sendData("e"); + }; + + + + // Process an ad impression event + o.adImpression = function() { + sendData("i"); + }; + + + + // Process a time tick event + o.time = function(vp, vd) { + // 0 or negative vd means live stream + if (vd < 1) { + // Initial tick means play() event + if (!uri.pw) { + uri.vd = 0; + uri.q = 0; + uri.pw = -1; + uri.ti = 20; + current = vp; + sendData("s"); + + // monitor ticks for 20s elapsed + } else { + if (vp - current > 20) { + current = vp; + sendData("t"); + } + } + + // positive vd means VOD stream + } else { + // Initial tick means play() event + if (!uri.vd) { + uri.vd = Math.round(vd); + if (vd < 30) { + uri.q = 1; + } else if (vd < 60) { + uri.q = 4; + } else if (vd < 180) { + uri.q = 8; + } else if (vd < 300) { + uri.q = 16; + } else { + uri.q = 32; + } + uri.ti = Math.round(uri.vd / uri.q); + uri.pw = 0; + sendData("s"); + + // monitor ticks for entering new quantile + } else { + let pw = Math.floor(vp / uri.ti) * 128 / uri.q; + if (pw != uri.pw) { + uri.pw = pw; + sendData("t"); + } + } + } + }; + + + + // Process a video complete events + o.complete = function() { + if (uri.pw != 128) { + uri.pw = 128; + sendData("t"); + } + }; + + + + // Helper function to generate IDs + function generateId(len) { + let arr = new Uint8Array((len || 40) / 2); + window.crypto.getRandomValues(arr); + return Array.from(arr, dec2hex).join(''); + }; + + function dec2hex(dec) { + return dec.toString(16).padStart(2, "0"); + }; + + + + // Serialize and send data to JW Player + function sendData(type) { + uri.e = type; + uri.sa = Date.now(); + // Serialize data + let str = ""; + for (let key in uri) { + if (uri[key] !== null) { + str == "" ? null : str += "&"; + str += key + "=" + encodeURIComponent(uri[key]); + } + } + // Ads are sent to special bucket + let url = URL; + if (uri.e == "i") { + url = URL.replace("jwplayer6", "clienta"); + } + // Send data if analytics token is set + if (uri.aid) { + navigator.sendBeacon(url + str); + } else { + console.log(url + str); + } + }; + + + +})(window.jwpltx); diff --git a/src/containers/Cinema/Cinema.tsx b/src/containers/Cinema/Cinema.tsx index 50bd7b851..7c8f53b1f 100644 --- a/src/containers/Cinema/Cinema.tsx +++ b/src/containers/Cinema/Cinema.tsx @@ -7,6 +7,7 @@ import { useWatchHistoryListener } from '../../hooks/useWatchHistoryListener'; import { watchHistoryStore, useWatchHistory } from '../../stores/WatchHistoryStore'; import { ConfigContext } from '../../providers/ConfigProvider'; import { addScript } from '../../utils/dom'; +import useOttAnalytics from '../../hooks/useOttAnalytics'; import styles from './Cinema.module.scss'; @@ -24,6 +25,7 @@ const Cinema: React.FC = ({ item, onPlay, onPause, onComplete, isTrailer const file = item.sources?.[0]?.file; const scriptUrl = `https://content.jwplatform.com/libraries/${config.player}.js`; const enableWatchHistory = config.options.enableContinueWatching && !isTrailer; + const setPlayer = useOttAnalytics(item); const getProgress = (): VideoProgress | null => { const player = window.jwplayer && (window.jwplayer('cinema') as jwplayer.JWPlayer); @@ -46,6 +48,7 @@ const Cinema: React.FC = ({ item, onPlay, onPause, onComplete, isTrailer let applyWatchHistory = !!watchHistory && enableWatchHistory; player.setup({ file, image: item.image, title: item.title, autostart: 'viewable' }); + setPlayer(player); player.on('play', () => onPlay && onPlay()); player.on('pause', () => onPause && onPause()); player.on('beforePlay', () => { @@ -62,7 +65,7 @@ const Cinema: React.FC = ({ item, onPlay, onPause, onComplete, isTrailer getPlayer() ? loadVideo() : addScript(scriptUrl, loadVideo); setInitialized(true); } - }, [item, onPlay, onPause, onComplete, config.player, file, scriptUrl, initialized, enableWatchHistory]); + }, [item, onPlay, onPause, onComplete, config.player, file, scriptUrl, initialized, enableWatchHistory, setPlayer]); return
; }; diff --git a/src/hooks/useOttAnalytics.ts b/src/hooks/useOttAnalytics.ts new file mode 100644 index 000000000..bd98c75b7 --- /dev/null +++ b/src/hooks/useOttAnalytics.ts @@ -0,0 +1,55 @@ +import { useContext, useEffect, useState } from 'react'; + +import { ConfigContext } from '../providers/ConfigProvider'; +import type { PlaylistItem } from '../../types/playlist'; + +const useOttAnalytics = (item?: PlaylistItem) => { + const config = useContext(ConfigContext); + const [player, setPlayer] = useState(); + + useEffect(() => { + if (!window.jwpltx || !config.analyticsToken || !player || !item) { + return; + } + + player.on('ready', () => { + if (!config.analyticsToken) return; + + window.jwpltx.ready( + config.analyticsToken, + window.location.hostname, + item.feedid, + item.mediaid, + item.title + ); + }); + + player.on('ready', () => { + if (!config.analyticsToken) return; + + window.jwpltx.ready( + config.analyticsToken, + window.location.hostname, + item.feedid, + item.mediaid, + item.title + ); + }); + + player.on('complete', () => { + window.jwpltx.complete(); + }); + + player.on('time', ({ position, duration }) => { + window.jwpltx.time(position, duration); + }); + + player.on('adImpression', () => { + window.jwpltx.adImpression(); + }); + }, [player]); + + return setPlayer; +}; + +export default useOttAnalytics; diff --git a/types/global.d.ts b/types/global.d.ts index 61c7d5759..0c861e1ba 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -2,4 +2,5 @@ interface Window { configLocation: configLocation; configId: string; jwplayer: (id: string) => unknown; + jwpltx: Jwpltx; } diff --git a/types/jwpltx.d.ts b/types/jwpltx.d.ts new file mode 100644 index 000000000..758882888 --- /dev/null +++ b/types/jwpltx.d.ts @@ -0,0 +1,6 @@ +interface Jwpltx { + ready: (analyticsId: string, hostname: string, feedid: string, mediaid: string, title: string) => void; + adImpression: () => void; + time: (position: number, duration: number) => void; + complete: () => void; +}