· 4 years ago · Aug 19, 2021, 11:28 PM
1twitch-videoad.js application/javascript
2(function() {
3 if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
4 function declareOptions(scope) {
5 // Options / globals
6 scope.OPT_ROLLING_DEVICE_ID = true;
7 scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
8 scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
9 scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false;
10 scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false;
11 scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome';
12 scope.OPT_REGULAR_PLAYER_TYPE = 'site';
13 scope.OPT_INITIAL_M3U8_ATTEMPTS = 1;
14 scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
15 scope.AD_SIGNIFIER = 'stitched-ad';
16 scope.LIVE_SIGNIFIER = ',live';
17 scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
18 // These are only really for Worker scope...
19 scope.StreamInfos = [];
20 scope.StreamInfosByUrl = [];
21 scope.CurrentChannelNameFromM3U8 = null;
22 // Need this in both scopes. Window scope needs to update this to worker scope.
23 scope.gql_device_id = null;
24 scope.gql_device_id_rolling = '';
25 // Rolling device id crap... TODO: improve this
26 var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); }
27 var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC';
28 var di = (new Date()).getUTCFullYear() + (new Date()).getUTCMonth() + ((new Date()).getUTCDate() / 7) | 0;
29 for (var i = 0; i < bs.length; i++) {
30 scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length];
31 }
32 }
33 declareOptions(window);
34 var twitchMainWorker = null;
35 const oldWorker = window.Worker;
36 window.Worker = class Worker extends oldWorker {
37 constructor(twitchBlobUrl) {
38 if (twitchMainWorker) {
39 super(twitchBlobUrl);
40 return;
41 }
42 var jsURL = getWasmWorkerUrl(twitchBlobUrl);
43 if (typeof jsURL !== 'string') {
44 super(twitchBlobUrl);
45 return;
46 }
47 var newBlobStr = `
48 ${processM3U8.toString()}
49 ${hookWorkerFetch.toString()}
50 ${declareOptions.toString()}
51 ${getAccessToken.toString()}
52 ${gqlRequest.toString()}
53 ${makeGraphQlPacket.toString()}
54 ${tryNotifyAdsWatchedM3U8.toString()}
55 ${parseAttributes.toString()}
56 declareOptions(self);
57 self.addEventListener('message', function(e) {
58 if (e.data.key == 'UboUpdateDeviceId') {
59 gql_device_id = e.data.value;
60 }
61 });
62 hookWorkerFetch();
63 importScripts('${jsURL}');
64 `
65 super(URL.createObjectURL(new Blob([newBlobStr])));
66 twitchMainWorker = this;
67 this.onmessage = function(e) {
68 // NOTE: Removed adDiv caching as '.video-player' can change between streams?
69 if (e.data.key == 'UboShowAdBanner') {
70 var adDiv = getAdDiv();
71 if (adDiv != null) {
72 adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...';
73 adDiv.style.display = 'block';
74 }
75 }
76 else if (e.data.key == 'UboHideAdBanner') {
77 var adDiv = getAdDiv();
78 if (adDiv != null) {
79 adDiv.style.display = 'none';
80 }
81 }
82 else if (e.data.key == 'UboFoundAdSegment') {
83 onFoundAd(e.data.isMidroll, e.data.streamM3u8);
84 }
85 else if (e.data.key == 'UboChannelNameM3U8Changed') {
86 //console.log('M3U8 channel name changed to ' + e.data.value);
87 }
88 else if (e.data.key == 'UboReloadPlayer') {
89 reloadTwitchPlayer();
90 }
91 else if (e.data.key == 'UboPauseResumePlayer') {
92 reloadTwitchPlayer(false, true);
93 }
94 else if (e.data.key == 'UboSeekPlayer') {
95 reloadTwitchPlayer(true);
96 }
97 }
98 function getAdDiv() {
99 var playerRootDiv = document.querySelector('.video-player');
100 var adDiv = null;
101 if (playerRootDiv != null) {
102 adDiv = playerRootDiv.querySelector('.ubo-overlay');
103 if (adDiv == null) {
104 adDiv = document.createElement('div');
105 adDiv.className = 'ubo-overlay';
106 adDiv.innerHTML = '<div class="player-ad-notice" style="color: white; background-color: rgba(0, 0, 0, 0.8); position: absolute; top: 0px; left: 0px; padding: 10px;"><p></p></div>';
107 adDiv.style.display = 'none';
108 adDiv.P = adDiv.querySelector('p');
109 playerRootDiv.appendChild(adDiv);
110 }
111 }
112 return adDiv;
113 }
114 }
115 }
116 function getWasmWorkerUrl(twitchBlobUrl) {
117 var req = new XMLHttpRequest();
118 req.open('GET', twitchBlobUrl, false);
119 req.send();
120 return req.responseText.split("'")[1];
121 }
122 async function processM3U8(url, textStr, realFetch) {
123 var streamInfo = StreamInfosByUrl[url];
124 if (streamInfo == null) {
125 console.log('Unknown stream url ' + url);
126 postMessage({key:'UboHideAdBanner'});
127 return textStr;
128 }
129 if (!OPT_MODE_STRIP_AD_SEGMENTS) {
130 return textStr;
131 }
132 var haveAdTags = textStr.includes(AD_SIGNIFIER);
133 if (haveAdTags) {
134 var currentResolution = null;
135 for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) {
136 if (resUrl == url) {
137 currentResolution = resName;
138 //console.log(resName);
139 break;
140 }
141 }
142 streamInfo.HadAds[url] = true;
143 streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
144 postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
145 // Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early.
146 // Deferred to after backup obtained to reduce slowdown. Midrolls are futile.
147 if (OPT_MODE_NOTIFY_ADS_WATCHED && !streamInfo.IsMidroll && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) {
148 await tryNotifyAdsWatchedM3U8(textStr);
149 }
150 postMessage({
151 key: 'UboFoundAdSegment',
152 isMidroll: streamInfo.IsMidroll,
153 streamM3u8: textStr
154 });
155 if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) {
156 return '';
157 }
158 // NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form.
159 // See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request)
160 try {
161 if (streamInfo.BackupRegRes != currentResolution) {
162 streamInfo.BackupRegRes = null;
163 streamInfo.BackupRegUrl = null;
164 }
165 if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) {
166 var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE);
167 if (accessTokenResponse.status === 200) {
168 var accessToken = await accessTokenResponse.json();
169 var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
170 urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
171 urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
172 var encodingsM3u8Response = await realFetch(urlInfo.href);
173 if (encodingsM3u8Response.status === 200) {
174 var encodingsM3u8 = await encodingsM3u8Response.text();
175 var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
176 for (var i = 0; i < encodingsLines.length; i++) {
177 if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) {
178 if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
179 var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION'];
180 if (res && res == currentResolution) {
181 streamInfo.BackupRegUrl = encodingsLines[i];
182 streamInfo.BackupRegRes = currentResolution;
183 break;
184 }
185 }
186 }
187 }
188 }
189 }
190 }
191 if (streamInfo.BackupRegUrl != null) {
192 var backupM3u8 = null;
193 var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl);
194 if (backupM3u8Response.status == 200) {
195 backupM3u8 = await backupM3u8Response.text();
196 }
197 if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
198 return backupM3u8;
199 } else {
200 //console.log('Try use regular resolution failed');
201 streamInfo.BackupRegRes = null;
202 streamInfo.BackupRegUrl = null;
203 }
204 }
205 } catch (err) {
206 streamInfo.BackupRegRes = null;
207 streamInfo.BackupRegUrl = null;
208 console.log('Fetching backup (regular resolution) m3u8 failed');
209 console.log(err);
210 }
211 // Fetch backup url
212 try {
213 if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
214 // NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this.
215 streamInfo.BackupFailed = true;
216 var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_BACKUP_PLAYER_TYPE);
217 if (accessTokenResponse.status === 200) {
218 var accessToken = await accessTokenResponse.json();
219 var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
220 urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
221 urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
222 var encodingsM3u8Response = await realFetch(urlInfo.href);
223 if (encodingsM3u8Response.status === 200) {
224 // TODO: Maybe look for the most optimal m3u8
225 var encodingsM3u8 = await encodingsM3u8Response.text();
226 var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
227 // Maybe this request is a bit unnecessary
228 var streamM3u8Response = await realFetch(streamM3u8Url);
229 if (streamM3u8Response.status == 200) {
230 streamInfo.BackupFailed = false;
231 streamInfo.BackupUrl = streamM3u8Url;
232 console.log('Fetched backup url: ' + streamInfo.BackupUrl);
233 } else {
234 console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status);
235 }
236 } else {
237 console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status);
238 }
239 } else {
240 console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status);
241 }
242 }
243 if (streamInfo.BackupUrl != null) {
244 var backupM3u8 = null;
245 var backupM3u8Response = await realFetch(streamInfo.BackupUrl);
246 if (backupM3u8Response.status == 200) {
247 backupM3u8 = await backupM3u8Response.text();
248 }
249 if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
250 return backupM3u8;
251 } else {
252 console.log('Backup m3u8 failed with ' + backupM3u8Response.status);
253 }
254 }
255 } catch (err) {
256 console.log('Fetching backup m3u8 failed');
257 console.log(err);
258 }
259 // Backups failed. Return nothing and reload the player (reload required as an empty result will terminate the stream).
260 console.log('Ad blocking failed. Stream might break.');
261 postMessage({key:'UboReloadPlayer'});
262 streamInfo.BackupFailed = false;
263 streamInfo.BackupUrl = null;
264 return '';
265 }
266 if (streamInfo.HadAds[url]) {
267 postMessage({key:'UboPauseResumePlayer'});
268 streamInfo.HadAds[url] = false;
269 }
270 postMessage({key:'UboHideAdBanner'});
271 return textStr;
272 }
273 function hookWorkerFetch() {
274 var realFetch = fetch;
275 fetch = async function(url, options) {
276 if (typeof url === 'string') {
277 if (url.endsWith('m3u8')) {
278 return new Promise(function(resolve, reject) {
279 var processAfter = async function(response) {
280 var str = await processM3U8(url, await response.text(), realFetch);
281 resolve(new Response(str));
282 };
283 var send = function() {
284 return realFetch(url, options).then(function(response) {
285 processAfter(response);
286 })['catch'](function(err) {
287 console.log('fetch hook err ' + err);
288 reject(err);
289 });
290 };
291 send();
292 });
293 }
294 else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) {
295 var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
296 if (CurrentChannelNameFromM3U8 != channelName) {
297 postMessage({
298 key: 'UboChannelNameM3U8Changed',
299 value: channelName
300 });
301 }
302 CurrentChannelNameFromM3U8 = channelName;
303 if (OPT_MODE_STRIP_AD_SEGMENTS) {
304 return new Promise(async function(resolve, reject) {
305 // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
306 // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
307 var maxAttempts = OPT_INITIAL_M3U8_ATTEMPTS <= 0 ? 1 : OPT_INITIAL_M3U8_ATTEMPTS;
308 var attempts = 0;
309 while(true) {
310 var encodingsM3u8Response = await realFetch(url, options);
311 if (encodingsM3u8Response.status === 200) {
312 var encodingsM3u8 = await encodingsM3u8Response.text();
313 var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
314 var streamM3u8Response = await realFetch(streamM3u8Url);
315 var streamM3u8 = await streamM3u8Response.text();
316 if (!streamM3u8.includes(AD_SIGNIFIER) || ++attempts >= maxAttempts) {
317 if (maxAttempts > 1 && attempts >= maxAttempts) {
318 console.log('max skip ad attempts reached (attempt #' + attempts + ')');
319 }
320 var streamInfo = StreamInfos[channelName];
321 if (streamInfo == null) {
322 StreamInfos[channelName] = streamInfo = {};
323 }
324 // This might potentially backfire... maybe just add the new urls
325 streamInfo.ChannelName = channelName;
326 streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution)
327 streamInfo.RootM3U8Url = url;
328 streamInfo.RootM3U8Params = (new URL(url)).search;
329 streamInfo.BackupUrl = null;
330 streamInfo.BackupFailed = false;
331 streamInfo.BackupRegUrl = null;
332 streamInfo.BackupRegRes = null;
333 streamInfo.IsMidroll = false;
334 streamInfo.HadAds = [];// xxx.m3u8 -> bool (had ads on prev request)
335 var lines = encodingsM3u8.replace('\r', '').split('\n');
336 for (var i = 0; i < lines.length; i++) {
337 if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
338 streamInfo.Urls[lines[i]] = -1;
339 if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
340 var res = parseAttributes(lines[i - 1])['RESOLUTION'];
341 if (res) {
342 streamInfo.Urls[lines[i]] = res;
343 }
344 }
345 streamInfo.HadAds[lines[i]] = false;
346 StreamInfosByUrl[lines[i]] = streamInfo;
347 }
348 }
349 resolve(new Response(encodingsM3u8));
350 break;
351 }
352 console.log('attempt to skip ad (attempt #' + attempts + ')');
353 } else {
354 // Stream is offline?
355 resolve(encodingsM3u8Response);
356 break;
357 }
358 }
359 });
360 }
361 }
362 }
363 return realFetch.apply(this, arguments);
364 }
365 }
366 function makeGraphQlPacket(event, radToken, payload) {
367 return [{
368 operationName: 'ClientSideAdEventHandling_RecordAdEvent',
369 variables: {
370 input: {
371 eventName: event,
372 eventPayload: JSON.stringify(payload),
373 radToken,
374 },
375 },
376 extensions: {
377 persistedQuery: {
378 version: 1,
379 sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b',
380 },
381 },
382 }];
383 }
384 function getAccessToken(channelName, playerType, realFetch) {
385 var body = null;
386 var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
387 body = {
388 operationName: 'PlaybackAccessToken_Template',
389 query: templateQuery,
390 variables: {
391 'isLive': true,
392 'login': channelName,
393 'isVod': false,
394 'vodID': '',
395 'playerType': playerType
396 }
397 };
398 return gqlRequest(body, realFetch);
399 }
400 function gqlRequest(body, realFetch) {
401 var fetchFunc = realFetch ? realFetch : fetch;
402 return fetchFunc('https://gql.twitch.tv/gql', {
403 method: 'POST',
404 body: JSON.stringify(body),
405 headers: {
406 'client-id': CLIENT_ID,
407 'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id
408 }
409 });
410 }
411 function parseAttributes(str) {
412 return Object.fromEntries(
413 str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/)
414 .filter(Boolean)
415 .map(x => {
416 const idx = x.indexOf('=');
417 const key = x.substring(0, idx);
418 const value = x.substring(idx +1);
419 const num = Number(value);
420 return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]
421 }));
422 }
423 async function tryNotifyAdsWatchedM3U8(streamM3u8) {
424 try {
425 //console.log(streamM3u8);
426 if (!streamM3u8.includes(AD_SIGNIFIER)) {
427 return 1;
428 }
429 var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/);
430 if (matches.length > 1) {
431 const attrString = matches[1];
432 const attr = parseAttributes(attrString);
433 var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1');
434 var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0');
435 var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN'];
436 var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID'];
437 var orderId = attr['X-TV-TWITCH-AD-ORDER-ID'];
438 var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID'];
439 var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID'];
440 var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase();
441 const baseData = {
442 stitched: true,
443 roll_type: rollType,
444 player_mute: false,
445 player_volume: 0.5,
446 visible: true,
447 };
448 for (let podPosition = 0; podPosition < podLength; podPosition++) {
449 if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) {
450 // This is all that's actually required at the moment
451 await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
452 } else {
453 const extendedData = {
454 ...baseData,
455 ad_id: adId,
456 ad_position: podPosition,
457 duration: 30,
458 creative_id: creativeId,
459 total_ads: podLength,
460 order_id: orderId,
461 line_item_id: lineItemId,
462 };
463 await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData));
464 for (let quartile = 0; quartile < 4; quartile++) {
465 await gqlRequest(
466 makeGraphQlPacket('video_ad_quartile_complete', radToken, {
467 ...extendedData,
468 quartile: quartile + 1,
469 })
470 );
471 }
472 await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
473 }
474 }
475 }
476 return 0;
477 } catch (err) {
478 console.log(err);
479 return 0;
480 }
481 }
482 function hookFetch() {
483 var realFetch = window.fetch;
484 window.fetch = function(url, init, ...args) {
485 if (typeof url === 'string') {
486 if (url.includes('/access_token') || url.includes('gql')) {
487 if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
488 if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
489 const newBody = JSON.parse(init.body);
490 newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
491 init.body = JSON.stringify(newBody);
492 }
493 }
494 var deviceId = init.headers['X-Device-Id'];
495 if (typeof deviceId !== 'string') {
496 deviceId = init.headers['Device-ID'];
497 }
498 if (typeof deviceId === 'string') {
499 gql_device_id = deviceId;
500 }
501 if (gql_device_id && twitchMainWorker) {
502 twitchMainWorker.postMessage({
503 key: 'UboUpdateDeviceId',
504 value: gql_device_id
505 });
506 }
507 if (OPT_ROLLING_DEVICE_ID) {
508 if (typeof init.headers['X-Device-Id'] === 'string') {
509 init.headers['X-Device-Id'] = gql_device_id_rolling;
510 }
511 if (typeof init.headers['Device-ID'] === 'string') {
512 init.headers['Device-ID'] = gql_device_id_rolling;
513 }
514 }
515 }
516 }
517 return realFetch.apply(this, arguments);
518 }
519 }
520 function onFoundAd(isMidroll, streamM3u8) {
521 if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) {
522 console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT');
523 if (streamM3u8) {
524 tryNotifyAdsWatchedM3U8(streamM3u8);
525 }
526 reloadTwitchPlayer();
527 }
528 }
529 function reloadTwitchPlayer(isSeek, isPausePlay) {
530 // Taken from ttv-tools / ffz
531 // https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
532 // https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
533 function findReactNode(root, constraint) {
534 if (root.stateNode && constraint(root.stateNode)) {
535 return root.stateNode;
536 }
537 let node = root.child;
538 while (node) {
539 const result = findReactNode(node, constraint);
540 if (result) {
541 return result;
542 }
543 node = node.sibling;
544 }
545 return null;
546 }
547 var reactRootNode = null;
548 var rootNode = document.querySelector('#root');
549 if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) {
550 reactRootNode = rootNode._reactRootContainer._internalRoot.current;
551 }
552 if (!reactRootNode) {
553 console.log('Could not find react root');
554 return;
555 }
556 var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance);
557 player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null;
558 var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings);
559 if (!player) {
560 console.log('Could not find player');
561 return;
562 }
563 if (!playerState) {
564 console.log('Could not find player state');
565 return;
566 }
567 if (player.paused) {
568 return;
569 }
570 if (isSeek) {
571 console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered()));
572 var pos = player.getPosition();
573 player.seekTo(0);
574 player.seekTo(pos);
575 return;
576 }
577 if (isPausePlay) {
578 player.pause();
579 player.play();
580 return;
581 }
582 const sink = player.mediaSinkManager || (player.core ? player.core.mediaSinkManager : null);
583 if (sink && sink.video && sink.video._ffz_compressor) {
584 const video = sink.video;
585 const volume = video.volume ? video.volume : player.getVolume();
586 const muted = player.isMuted();
587 const newVideo = document.createElement('video');
588 newVideo.volume = muted ? 0 : volume;
589 newVideo.playsInline = true;
590 video.replaceWith(newVideo);
591 player.attachHTMLVideoElement(newVideo);
592 setImmediate(() => {
593 player.setVolume(volume);
594 player.setMuted(muted);
595 });
596 }
597 playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false
598 }
599 window.reloadTwitchPlayer = reloadTwitchPlayer;
600 hookFetch();
601 function onContentLoaded() {
602 // This stops Twitch from pausing the player when in another tab and an ad shows.
603 // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30
604 try {
605 Object.defineProperty(document, 'visibilityState', {
606 get() {
607 return 'visible';
608 }
609 });
610 }catch{}
611 try {
612 Object.defineProperty(document, 'hidden', {
613 get() {
614 return false;
615 }
616 });
617 }catch{}
618 var block = e => {
619 e.preventDefault();
620 e.stopPropagation();
621 e.stopImmediatePropagation();
622 };
623 document.addEventListener('visibilitychange', block, true);
624 document.addEventListener('webkitvisibilitychange', block, true);
625 document.addEventListener('mozvisibilitychange', block, true);
626 document.addEventListener('hasFocus', block, true);
627 try {
628 if (/Firefox/.test(navigator.userAgent)) {
629 Object.defineProperty(document, 'mozHidden', {
630 get() {
631 return false;
632 }
633 });
634 } else {
635 Object.defineProperty(document, 'webkitHidden', {
636 get() {
637 return false;
638 }
639 });
640 }
641 }catch{}
642 }
643 if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
644 onContentLoaded();
645 } else {
646 window.addEventListener("DOMContentLoaded", function() {
647 onContentLoaded();
648 });
649 }
650})();