1/* 2 * REVOLT.TV Floating Video Player 3 * This script is used to embed the revolt.tv promo video player on a website. 4 * 5 * COPYRIGHT (C) 2024 YOUR NAME. ALL RIGHTS RESERVED. 6 */ 7 8/* eslint-disable no-undef */ 9 10import { getMetadata } from './lib-franklin.js'; 11import { applyPrivacyToAdTag, resolveAdMode } from './privacy.js'; 12import { suppressedAdvertising } from './jw-ads.js'; 13 14const REVOLT_PLAYER_CONFIG = window.revolt_player_config; 15const PLAYER_ID = REVOLT_PLAYER_CONFIG[0]?.player_id || '14AVIIqU'; 16const PLAYLIST_ID = REVOLT_PLAYER_CONFIG[0]?.playlist_id || 'gJDQAhak'; 17const LABEL_ADVERTISEMENT = REVOLT_PLAYER_CONFIG[0]?.advertisement_label || 'Advertisement'; 18const LABEL_HEADING = REVOLT_PLAYER_CONFIG[0]?.heading_label || 'Revolt - New Episodes'; 19const LABEL_LINK_PREFIX = REVOLT_PLAYER_CONFIG[0]?.link_prefix_label || 'Watch:'; 20const PLAYER_DELAY = Number(REVOLT_PLAYER_CONFIG[0]?.player_delay) || 3000; 21const SHOW_PLAYER_ARROW = REVOLT_PLAYER_CONFIG[0]?.playlist_cta_arrow 22 ? JSON.parse(REVOLT_PLAYER_CONFIG[0]?.playlist_cta_arrow) 23 : true; 24const SHOW_PLAYER_TEXT = REVOLT_PLAYER_CONFIG[0]?.playlist_title 25 ? JSON.parse(REVOLT_PLAYER_CONFIG[0]?.playlist_title) 26 : true; 27const SHOW_PLAYER_LINK_AREA = REVOLT_PLAYER_CONFIG[0]?.playlist_title_area 28 ? JSON.parse(REVOLT_PLAYER_CONFIG[0]?.playlist_title_area) 29 : true; 30const PLAYER_TITLE = REVOLT_PLAYER_CONFIG[0]?.playlist_title_key || 'seriesTitle'; 31const PLAYER_TITLE_URL = REVOLT_PLAYER_CONFIG[0]?.playlist_title_url_key; 32const TITLE_LINE_LEN_DESKTOP = REVOLT_PLAYER_CONFIG[0]?.playlist_title_line_length_desktop || 2; 33const TITLE_LINE_LEN_MOBILE = REVOLT_PLAYER_CONFIG[0]?.playlist_title_line_length_mobile || 2; 34const PLAYLIST_MOBILE_CTA = REVOLT_PLAYER_CONFIG[0]?.playlist_mobile_cta 35 ? JSON.parse(REVOLT_PLAYER_CONFIG[0]?.playlist_mobile_cta) 36 : true; 37const MOBILE_CTA_LABEL = REVOLT_PLAYER_CONFIG[0]?.playlist_mobile_cta_label; 38const MOBILE_CTA_TARGET = REVOLT_PLAYER_CONFIG[0]?.playlist_mobile_cta_target; 39 40// Load stylesheet from /styles/revolt-player.css 41const styleSheet = document.createElement('link'); 42styleSheet.rel = 'stylesheet'; 43styleSheet.href = '/styles/revolt-player.css'; 44document.head.appendChild(styleSheet); 45 46// Load player script 47 48const jwplayerScript = document.createElement('script'); 49jwplayerScript.src = `https://cdn.jwplayer.com/libraries/${PLAYER_ID}.js`; 50jwplayerScript.async = true; 51document.head.appendChild(jwplayerScript); 52 53async function embedRevoltPlayer() { 54 // Player wrapper div 55 const containerDiv = document.createElement('div'); 56 containerDiv.classList.add('revolt-player-container'); 57 58 // Heading div 59 const headingDiv = document.createElement('div'); 60 headingDiv.classList.add('revolt-player-heading'); 61 const headingLabelDiv = document.createElement('div'); 62 headingLabelDiv.classList.add('revolt-player-heading-label'); 63 headingLabelDiv.innerHTML = LABEL_HEADING; 64 headingDiv.appendChild(headingLabelDiv); 65 66 // Mobile only link div 67 const mobileLinkDiv = document.createElement('div'); 68 mobileLinkDiv.classList.add('revolt-player-mobile-link'); 69 const mobileLinkElement = document.createElement('a'); 70 mobileLinkElement.href = 'https://www.revolt.tv'; // Default url used before video starts 71 mobileLinkElement.textContent = `${LABEL_LINK_PREFIX} Revolt.tv`; 72 mobileLinkElement.style.webkitLineClamp = TITLE_LINE_LEN_MOBILE; 73 mobileLinkDiv.appendChild(mobileLinkElement); 74 const watchNowButton = document.createElement('a'); 75 if (PLAYLIST_MOBILE_CTA) { 76 watchNowButton.classList.add('watch-now-btn'); 77 watchNowButton.href = MOBILE_CTA_TARGET || 'https://www.revolt.tv'; // Default url used before video starts 78 watchNowButton.innerHTML = MOBILE_CTA_LABEL || 'Watch Now'; 79 mobileLinkDiv.appendChild(watchNowButton); 80 } 81 headingDiv.appendChild(mobileLinkDiv); 82 83 const closeIcon = document.createElement('i'); 84 closeIcon.classList.add('revolt-player-close'); 85 closeIcon.style.display = 'none'; 86 headingDiv.appendChild(closeIcon); 87 const handleClose = () => { 88 containerDiv.remove(); 89 }; 90 91 containerDiv.appendChild(headingDiv); 92 93 const playerDiv = document.createElement('div'); 94 playerDiv.id = 'revolt-player'; 95 containerDiv.appendChild(playerDiv); 96 97 const linkDiv = document.createElement('div'); 98 linkDiv.classList.add(SHOW_PLAYER_LINK_AREA ? 'revolt-player-link' : 'revolt-player-link-hide'); 99 100 const linkElement = document.createElement('a'); 101 if (SHOW_PLAYER_TEXT) { 102 linkElement.href = 'https://www.revolt.tv'; // Default url used before video starts 103 linkElement.textContent = `${LABEL_LINK_PREFIX} Revolt.tv`; 104 } 105 linkElement.style.webkitLineClamp = TITLE_LINE_LEN_DESKTOP; 106 linkDiv.appendChild(linkElement); 107 108 const nextImageLinkElement = document.createElement('a'); 109 nextImageLinkElement.href = 'https://www.revolt.tv'; // Default url used before video starts 110 nextImageLinkElement.classList.add(SHOW_PLAYER_ARROW ? 'revolt-player-next' : 'revolt-player-next-hide'); 111 const nextImage = document.createElement('img'); 112 nextImage.src = '/icons/revolt-player-next.png'; 113 nextImage.alt = 'Watch Episode'; 114 nextImageLinkElement.appendChild(nextImage); 115 linkDiv.appendChild(nextImageLinkElement); 116 117 const learnMoreButton = document.createElement('a'); 118 learnMoreButton.classList.add('learn-more-btn'); 119 learnMoreButton.innerHTML = 'Learn More'; 120 121 const handleAdRedirection = (event, adClickURL) => { 122 window.open(adClickURL, '_blank'); 123 }; 124 125 containerDiv.appendChild(linkDiv); 126 127 document.body.appendChild(containerDiv); 128 129 // Fetch safetags from the current page. Kept as RAW members â every value 130 // that goes into cust_params is encoded once, at assembly, by encodeNested(). 131 // 132 // Resolves TRUE when the brand-safety list was obtained and FALSE when it 133 // never arrived. The caller has to be able to tell "this page excludes no 134 // advertisers" from "we could not find out", because only the first is safe 135 // to serve an ad against: both used to collapse to an empty `safetags`, and 136 // stripEmptyParams() then dropped safetag_exclude_list from the tag 137 // altogether, so a brand-safety outage served a completely unfiltered VAST 138 // request. An empty Exclude IS a known answer â `Exclude: []` resolves true. 139 let safetags = []; 140 const maxRetries = 10; 141 const retryInterval = 250; // 500ms 142 function getSafetags() { 143 return new Promise((resolve) => { 144 let retryCount = 0; 145 146 function checkSafetags() { 147 if (window.safetag_lists && window.safetag_lists.Exclude) { 148 if (window.safetag_lists.Exclude.length > 0) { 149 safetags = window.safetag_lists.Exclude; 150 } 151 // eslint-disable-next-line no-console 152 console.log('player safetags', safetags); 153 154 resolve(true); 155 } else if (retryCount < maxRetries) { 156 retryCount += 1; 157 setTimeout(checkSafetags, retryInterval); 158 } else { 159 // eslint-disable-next-line no-console 160 console.error('revolt-player: brand-safety list unavailable after maximum retries â suppressing the ad schedule', window.location.pathname); 161 resolve(false); 162 } 163 } 164 165 checkSafetags(); 166 }); 167 } 168 169 const playerDelay = typeof jwplayer === 'undefined' ? 1000 : 0; 170 setTimeout(async () => { 171 // Player library 14AVIIqU carries a cloud-hosted ad schedule (8bHeWxdZ) 172 // which serves an ad â measured, including an impression â when no 173 // `advertising` key is passed at all: JW reads the missing key as "use my 174 // cloud schedule". That cloud ad never appears in 175 // getConfig().advertising.schedule, so it cannot be stamped, which is why 176 // this player previously had to suppress opted-out visitors outright 177 // (cloudAdvertising({ stampable: false })) â correct on compliance, but it 178 // cost every eligible opt-out impression. It now passes its OWN schedule, 179 // built and stamped below: an explicit schedule drops the cloud 180 // adscheduleid (measured on 14AVIIqU), so the tags that go out are ours
181 // and an eligible US opt-out visitor can be served a non-personalized ad 182 // instead of nothing. (WEB-606) 183 // 184 // Both are STARTED here so they overlap, and the ad mode is resolved FIRST 185 // so the safetag poll is only ever waited on when it will be used. 186 // getSafetags() polls for up to maxRetries x retryInterval (~2.5s) when 187 // window.safetag_lists never appears, and a visitor who is getting an empty 188 // schedule anyway must not pay that wait before the CONTENT player is set 189 // up. On the serving path the wait is required: building a tag without the 190 // list sends an empty safetag_exclude_list, i.e. an unfiltered ad call on a 191 // page the brand-safety scan may have excluded advertisers from. 192 const adModePromise = resolveAdMode(); 193 const safetagsPromise = getSafetags(); 194 195 const { load, nonPersonalized } = await adModePromise; 196 // Only the serving path needs the list, so only it pays the poll. A 197 // suppressed visitor is already getting an empty schedule. 198 const safetagsKnown = load ? await safetagsPromise : false; 199 // Fail CLOSED on a brand-safety miss: without the exclusion list we cannot 200 // assert the request is filtered, and awaiting the poll would otherwise 201 // only delay an unfiltered ad call rather than prevent one. 202 // 203 // This deliberately diverges from the display stack, which fails OPEN on 204 // the same signal (see the documented decision in scripts.js:821), so 205 // during a brand-safety outage this page serves unfiltered display ads 206 // while video stays dark. Raised on PR #1885 and settled there: video 207 // stays fail-closed, because withholding a video impression is cheaper 208 // than serving one the brand-safety scan never cleared. Deliberate, not an 209 // oversight â do not "fix" the inconsistency by dropping `&& safetagsKnown` 210 // here. Closing it the other way (display fail-closed too) is the better 211 // change and needs its own ticket. 212 const serveAds = load && safetagsKnown; 213 214 // Retrieve metadata values once 215 const metadata = { 216 internalTags: (getMetadata('internal-tags') || '') 217 .split(',') 218 .map((tag) => tag.trim()) 219 .filter((tag) => tag !== ''), 220 post_id: getMetadata('post_id'), 221 // Normalised the same way as the display-ad targeting in scripts.js so 222 // both ad calls send an identical `category` value for GAM to key line 223 // items on. The authored tag in revolt-player-config already carries a 224 // {category} placeholder that nothing was substituting, so the literal 225 // token would otherwise go out as the value. 226 categories: (getMetadata('categories') || '') 227 .split(',') 228 .map((category) => category.trim().toLowerCase().replace(/\s+/g, '-')) 229 .filter((category) => category !== ''), 230 }; 231 232 // cust_params is itself a percent-encoded query string NESTED inside the tag 233 // URL, so GAM decodes the outer layer before parsing the inner key/value 234 // pairs. A value has to survive BOTH decodes: one encodeURIComponent leaves 235 // a reserved separator intact after the outer decode, which terminates the 236 // pair and invents a key â `AT&T` in the exclude list would arrive as 237 // `safetag_exclude_list=AT` plus a stray `T`, silently dropping a 238 // brand-safety exclusion. Encode twice so the inner layer sees `AT%26T`. 239 const encodeNested = (value) => encodeURIComponent(encodeURIComponent(value)); 240 // Multi-value keys: GAM reads a LITERAL comma inside cust_params as its 241 // value separator, so each member is encoded on its own and joined with a 242 // bare comma. Encoding the joined string would send `news%2Crap-up` â one 243 // value â where the display targeting in scripts.js sends two. 244 const encodeNestedList = (values) => values.map(encodeNested).join(','); 245 246 const params = { 247 safetags: safetags.length ? encodeNestedList(safetags) : '', 248 internal_tag: metadata.internalTags.length ? encodeNestedList(metadata.internalTags) : '', 249 post_id: metadata.post_id ? encodeNested(metadata.post_id) : '', 250 category: metadata.categories.length ? encodeNestedList(metadata.categories) : '', 251 }; 252 253 let preRolladTagUrl = REVOLT_PLAYER_CONFIG[0]?.preroll_ad_tag_url || 'https://pubads.g.doubleclick.net/gampad/ads?iu=/21787171759/revolt.tv/site/preroll_float_640x480v&description_url={currentPage}&sz=640x480&gdfp_req=1&output=xml_vast2&unviewed_position_start=1&env=vp&cust_params=safetag_exclude_list%3D{safetags}%26platform%3Dfloating%26internal_tag%3D{internal_tag}%26post_id%3D{post_id}%26category%3D{category}'; 254 255 let midRolladTagUrl = REVOLT_PLAYER_CONFIG[0]?.midroll_ad_tag_url || 'https://pubads.g.doubleclick.net/gampad/ads?iu=/21787171759/revolt.tv/site/midroll_float_640x480v&description_url={currentPage}&sz=640x480&gdfp_req=1&output=xml_vast2&unviewed_position_start=1&env=vp&cust_params=safetag_exclude_list%3D{safetags}%26platform%3Dfloating%26internal_tag%3D{internal_tag}%26post_id%3D{post_id}%26category%3D{category}'; 256 257 preRolladTagUrl = preRolladTagUrl.replace('{currentPage}', encodeURIComponent(window.location.href)); 258 midRolladTagUrl = midRolladTagUrl.replace('{currentPage}', encodeURIComponent(window.location.href)); 259 260 // Replace placeholders dynamically
261 Object.keys(params).forEach((key) => { 262 preRolladTagUrl = preRolladTagUrl.replace(`{${key}}`, params[key]); 263 midRolladTagUrl = midRolladTagUrl.replace(`{${key}}`, params[key]); 264 }); 265 266 const rngCorrelator = Math.floor(Math.random() * 9999999).toString(); 267 preRolladTagUrl = `${preRolladTagUrl}&correlator=${rngCorrelator}`; 268 midRolladTagUrl = `${midRolladTagUrl}&correlator=${rngCorrelator}`; 269 270 // Drop any cust_params key whose value came out empty. 271 // 272 // Scoped to the cust_params VALUE, not the whole URL: description_url 273 // carries the percent-encoded page URL, so a visitor arriving with an 274 // empty query param of their own (`?utm_source=x&utm_campaign=`) has a 275 // `%26utm_campaign%3D` sitting immediately before the tag's raw 276 // `&cust_params`. Run over the whole URL, this silently deleted that param 277 // from the page URL GAM reads for contextual targeting. 278 // 279 // Within the value, keys are separated by %26 and terminated by end of 280 // value. `(^|%26)` covers the FIRST key too â safetag_exclude_list leads the 281 // value, so an empty exclude list (or a brand-safety timeout) otherwise sent 282 // `safetag_exclude_list=` despite this "drop any empty key" pass. Removing a 283 // leading key can leave the value starting with %26, which is trimmed after. 284 // The lookahead (rather than a greedy `[^=]+`) is what keeps an empty LAST 285 // key from swallowing every key before it â a page with no categories used 286 // to lose platform, internal_tag and post_id too. 287 // See revolt-player-ad-tag.test.mjs. 288 const stripEmptyParams = (tag) => tag 289 .replace(/([?&]cust_params=)([^&]*)/, (_, prefix, value) => prefix + value 290 .replace(/(^|%26)\w+%3D(?=%26|$)/g, '') 291 .replace(/^%26/, '')) 292 .replace(/(&|\?)$/, ''); 293 294 preRolladTagUrl = stripEmptyParams(preRolladTagUrl); 295 midRolladTagUrl = stripEmptyParams(midRolladTagUrl); 296 297 // Stamp the visitor's US Privacy opt-out state onto the GAM tags 298 // (covers tags supplied via AEM revolt-player-config as well), using the 299 // same resolved opt-out state the gate above used rather than a second, 300 // possibly inconsistent read 301 preRolladTagUrl = applyPrivacyToAdTag(preRolladTagUrl, nonPersonalized); 302 midRolladTagUrl = applyPrivacyToAdTag(midRolladTagUrl, nonPersonalized); 303 304 const playerSetup = { 305 playlist: `https://cdn.jwplayer.com/v2/playlists/${PLAYLIST_ID}?format=json`, 306 autostart: true, 307 mute: true, 308 }; 309 310 // An eligible US opt-out visitor is served this schedule too â every tag 311 // in it was stamped npa=1/rdp=1/us_privacy=1YYY by applyPrivacyToAdTag() 312 // above, using the same resolved state this gate reads. Anyone who must not 313 // be served â an opt-in region with no consent, an unknown region, 314 // OneTrust never resolving, or a brand-safety list we never obtained â 315 // gets an EXPLICIT empty schedule rather than no `advertising` key, 316 // because omitting it hands them the cloud schedule. 317 playerSetup.advertising = serveAds 318 ? { 319 client: 'vast', 320 schedule: [ 321 { offset: 'pre', tag: preRolladTagUrl }, 322 { offset: 30, tag: midRolladTagUrl }, 323 { offset: 60, tag: midRolladTagUrl }, 324 { offset: 90, tag: midRolladTagUrl }, 325 { offset: 120, tag: midRolladTagUrl }, 326 { offset: 150, tag: midRolladTagUrl }, 327 { offset: 180, tag: midRolladTagUrl }, 328 { offset: 210, tag: midRolladTagUrl }, 329 { offset: 240, tag: midRolladTagUrl }, 330 { offset: 270, tag: midRolladTagUrl }, 331 ], 332 } 333 : suppressedAdvertising('vast'); 334 335 const player = jwplayer('revolt-player').setup(playerSetup); 336 337 player.on('adTime', () => { 338 closeIcon.style.display = 'none'; 339 closeIcon.removeEventListener('click', handleClose); 340 }); 341 342 player.on('play', () => { 343 if (!player.getState().includes('ad')) { 344 closeIcon.style.display = 'block'; // Show only if not an ad 345 closeIcon.addEventListener('click', handleClose); 346 // Set link to show url and video title 347 const playlistItem = jwplayer('revolt-player').getPlaylistItem(); 348 const playerTitle = playlistItem[PLAYER_TITLE] || playlistItem.seriesTitle; 349 const playerTitleURL = playlistItem[PLAYER_TITLE_URL]; 350 351 const seriesUrl = playerTitle.replace(/: /g, '-').replace(/[^a-zA-Z0-9- ]+/g, '').replace(/ /g, '-').toLowerCase(); 352 const showUrl = `https://www.revolt.tv/show/${seriesUrl}?ref=sticky_player`; 353 if (SHOW_PLAYER_TEXT) { 354 linkElement.href = playerTitleURL || showUrl; 355 linkElement.textContent = `${LABEL_LINK_PREFIX} ${playerTitle}`; 356 } 357 358 mobileLinkElement.textContent = playerTitle; 359 mobileLinkElement.href = playerTitleURL || showUrl; 360 watchNowButton.href = playerTitleURL || showUrl; 361 if (SHOW_PLAYER_ARROW) { 362 nextImageLinkElement.href = playerTitleURL || showUrl; 363 } 364 } 365 }); 366 367 player.on('adStarted', () => { 368 closeIcon.style.display = 'none'; 369 closeIcon.removeEventListener('click', handleClose); 370 }); 371 372 // Advertisment Start Event - Update title, link 373 jwplayer('revolt-player').on('adImpression', (event) => { 374 const adClickURL = event.clickThroughUrl; 375 376 document.querySelector('.revolt-player-heading').style.display = 'none'; 377 if (!adClickURL) { 378 const container = document.querySelector('.revolt-player-container'); 379 Object.assign(container.style, { 380 background: 'black', 381 gridTemplateColumns: '50%', 382 justifyContent: 'center', 383 }); 384 } 385 watchNowButton.style.display = 'none'; 386 learnMoreButton.addEventListener('click', (clickEvent) => { 387 if (adClickURL) { 388 handleAdRedirection(clickEvent, adClickURL); 389 mobileLinkDiv.appendChild(learnMoreButton); 390 } 391 }); 392 closeIcon.style.display = 'none'; 393 closeIcon.removeEventListener('click', handleClose); 394 mobileLinkElement.style.display = 'none'; 395
396 // Change title to Advertisement 397 headingLabelDiv.innerHTML = LABEL_ADVERTISEMENT; 398 399 // Hide links 400 if (!linkDiv.classList.contains('hide-link')) { 401 linkDiv.classList.add('hide-link'); 402 } 403 }); 404 405 // Advertisement End Event - Update title, link 406 jwplayer('revolt-player').on('adComplete adSkipped', () => { 407 document.querySelector('.revolt-player-heading').style.display = 'grid'; 408 document.querySelector('.revolt-player-container').style.gridTemplateColumns = '50% 50%'; 409 410 watchNowButton.style.display = 'block'; 411 412 learnMoreButton.removeEventListener('click', () => handleAdRedirection()); 413 closeIcon.style.display = 'block'; 414 closeIcon.addEventListener('click', handleClose); 415 416 mobileLinkElement.style.display = '-webkit-box'; 417 418 learnMoreButton.remove(); 419 420 // Reset title 421 headingLabelDiv.innerHTML = LABEL_HEADING; 422 423 // Show links 424 if (linkDiv.classList.contains('hide-link')) { 425 linkDiv.classList.remove('hide-link'); 426 } 427 428 // Set link to show url and video title 429 const playlistItem = jwplayer('revolt-player').getPlaylistItem(); 430 const playerTitle = playlistItem[PLAYER_TITLE] || playlistItem.seriesTitle; 431 const playerTitleURL = playlistItem[PLAYER_TITLE_URL]; 432 const seriesUrl = playerTitle.replace(/: /g, '-').replace(/[^a-zA-Z0-9- ]+/g, '').replace(/ /g, '-').toLowerCase(); 433 const showUrl = `https://www.revolt.tv/show/${seriesUrl}?ref=sticky_player`; 434 if (SHOW_PLAYER_TEXT) { 435 linkElement.textContent = `${LABEL_LINK_PREFIX} ${playerTitle}`; 436 linkElement.href = playerTitleURL || showUrl; 437 } 438 439 mobileLinkElement.textContent = playerTitle; 440 mobileLinkElement.href = playerTitleURL || showUrl; 441 watchNowButton.href = playerTitleURL || showUrl; 442 if (SHOW_PLAYER_ARROW) { 443 nextImageLinkElement.href = playerTitleURL || showUrl; 444 } 445 }); 446 }, playerDelay); 447} 448 449// Call revolt floating video player 450const additionalPlayerDelay = window.revoltAddDelay || 0; 451const totalPlayerDelay = PLAYER_DELAY + additionalPlayerDelay; 452 453setTimeout(() => { 454 embedRevoltPlayer(); 455}, totalPlayerDelay);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.