Last active
August 30, 2024 10:44
-
-
Save lbmaian/fef815da2628dc6e546ba1f7cd8212a5 to your computer and use it in GitHub Desktop.
HoloTools Fixes and Enhancements
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name HoloTools Fixes and Enhancements | |
// @namespace https://gist.github.com/lbmaian/fef815da2628dc6e546ba1f7cd8212a5 | |
// @downloadURL https://gist.github.com/lbmaian/fef815da2628dc6e546ba1f7cd8212a5/raw/holotools-fixes.user.js | |
// @updateURL https://gist.github.com/lbmaian/fef815da2628dc6e546ba1f7cd8212a5/raw/holotools-fixes.user.js | |
// @version 0.6 | |
// @description HoloTools Fixes and Enhancements | |
// @author lbmaian | |
// @match https://hololive.jetri.co/ | |
// @match https://holodex.net/login | |
// @icon https://hololive.jetri.co/favicon-fbk.png | |
// @run-at document-start | |
// @grant GM_cookie | |
// @grant GM_xmlhttpRequest | |
// @grant GM_openInTab | |
// @grant GM_getValue | |
// @grant GM_setValue | |
// @grant GM_addValueChangeListener | |
// @grant GM_removeValueChangeListener | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
const DEBUG = false; | |
const logContext = '[Holodex Fixes and Enhancements]'; | |
var debug; | |
if (DEBUG) { | |
debug = function(...args) { | |
console.debug(logContext, ...args); | |
}; | |
} else { | |
debug = function() {}; | |
} | |
function log(...args) { | |
console.log(logContext, ...args); | |
} | |
function info(...args) { | |
console.info(logContext, ...args); | |
} | |
function warn(...args) { | |
console.warn(logContext, ...args); | |
} | |
function error(...args) { | |
console.error(logContext, ...args); | |
} | |
//////// X-APIKEY GETTER //////// | |
// @match https://holodex.net/login is needed for below GM_cookie or Holodex login page fallback. | |
if (location.origin === 'https://holodex.net') { | |
// if (parent === self) { // note: can't check parent.location due to CORS security | |
// return; | |
// } | |
// For security reasons, document.cookie in cross-origin iframe doesn't contain SameSite (default) non-Secure cookies. | |
// It is available for non-iframes, but we might as well capture the API key rather than the HOLODEX_JWT cookie. | |
// debug('holodex.net document.cookie:', document.cookie); | |
// debug('holodex.net referrer:', document.referrer); | |
function proxyGetter(obj, prop, defineFunc) { | |
const origDesc = Object.getOwnPropertyDescriptor(obj, prop); | |
Object.defineProperty(obj, prop, { | |
...origDesc, | |
get: defineFunc(origDesc.get), | |
}); | |
} | |
const origXhrResponseTextDesc = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText'); | |
proxyGetter(XMLHttpRequest.prototype, 'responseText', (origGetter) => function() { | |
const responseText = origGetter.call(this); | |
if (this.responseURL === 'https://holodex.net/api/v2/user/refresh') { | |
const response = JSON.parse(responseText); | |
debug('Holodex user refresh response:', response); | |
//parent.postMessage(response?.user?.api_key, 'https://hololive.jetri.co'); | |
GM_setValue('holodexApiKey', response?.user?.api_key); | |
} | |
return responseText; | |
}); | |
// function proxyProperty(obj, prop, descKey, defineFunc) { | |
// const origDesc = Object.getOwnPropertyDescriptor(obj, prop); | |
// if (!origDesc) { | |
// return warn('could not find', prop, 'on', obj); | |
// } | |
// const origFunc = origDesc[descKey]; | |
// if (!origFunc) { | |
// return warn('could not find', descKey, 'for', prop, 'on', obj); | |
// } | |
// const func = defineFunc(origFunc, this); | |
// if (func.name !== origFunc.name) { | |
// Object.defineProperty(func, 'name', { value: origFunc.name }); // for console debugging purposes | |
// } | |
// Object.defineProperty(obj, prop, { | |
// ...origDesc, | |
// [descKey]: func, | |
// }); | |
// } | |
// Spoof visibility APIs to always be visible, since Holodex checks for this? | |
// proxyProperty(Document.prototype, 'hidden', 'get', (orig) => function() { | |
// debug('document.hidden (orig):', orig.call(this)); | |
// return true; | |
// }); | |
// proxyProperty(Document.prototype, 'visibilityState', 'get', (orig) => function() { | |
// debug('document.visibilityState (orig):', orig.call(this)); | |
// return 'visible'; | |
// }); | |
// proxyProperty(Document.prototype, 'hasFocus', 'value', (orig) => function hasFocus() { | |
// debug('document.hasFocus():', orig.call(this)); | |
// return true; | |
// }); | |
// proxyProperty(IntersectionObserverEntry.prototype, 'intersectionRatio', 'get', (orig) => function() { | |
// debug('IntersectionObserverEntry.intersectionRatio:', this, orig.call(this)); | |
// return 1.0; | |
// }); | |
// proxyProperty(IntersectionObserverEntry.prototype, 'isIntersecting', 'get', (orig) => function() { | |
// debug('IntersectionObserverEntry.isIntersecting:', this, orig.call(this)); | |
// return true; | |
// }); | |
// proxyProperty(IntersectionObserverEntry.prototype, 'isVisible', 'get', (orig) => function() { | |
// debug('IntersectionObserverEntry.isVisible:', this, orig.call(this)); | |
// return true; | |
// }); | |
return; | |
} | |
function getHolodexApiKey() { | |
return new Promise((resolve, reject) => { | |
//GM_setValue('holodexApiKey', null); // test note: uncomment to debug API key fetching | |
let holodexApiKey = GM_getValue('holodexApiKey'); | |
if (holodexApiKey) { | |
debug('Holodex API key already stored'); | |
return resolve(holodexApiKey); | |
} | |
//return reject('DEBUG TEST'); // test note: uncomment to debug Holodex login fallback | |
if (!window.GM_cookie || !GM_cookie.list) { | |
return reject('GM_cookie.list not supported'); | |
} | |
GM_cookie.list({ url: 'https://holodex.net/login', name: 'HOLODEX_JWT' }, (cookies, error) => { | |
if (error) { | |
return reject('Error getting Holodex cookies: ' + error); | |
} | |
debug('Holodex cookies:', cookies); | |
const holodexAuthToken = cookies[0]?.value; | |
if (!holodexAuthToken) { | |
return reject('Could not find HOLODEX_JWT in cookies'); | |
} | |
// Need to use this rather than native fetch/XHR for cross-origin requests with custom referer. | |
const url = 'https://holodex.net/api/v2/user/refresh'; | |
function onerror(response) { | |
debug('Holodex user refresh response (error):', response); | |
let errorReason = ''; | |
if (response.status) { | |
errorReason += ` with status ${response.status} (${response.request})` | |
} | |
if (response.error) { | |
errorReason += `: ${response.error}`; | |
} | |
const responseText = response.responseText; | |
if (responseText) { | |
errorReason += `: ${responseText}`; | |
} | |
reject(`Request to ${url} failed${errorReason}`); | |
} | |
GM_xmlhttpRequest({ | |
url, | |
method: 'GET', | |
responseType: 'json', | |
headers: { | |
'authorization': 'BEARER ' + holodexAuthToken, | |
'referer': 'https://holodex.net/login', | |
}, | |
onerror, | |
onload(response) { | |
if (response.status !== 200) { | |
return onerror(response); | |
} | |
debug('Holodex user refresh response:', response); | |
const holodexApiKey = response.response?.user?.api_key; | |
if (!holodexApiKey) { | |
reject(`Response from ${url} is missing API key: ${response.responseText}`); | |
} else { | |
resolve(holodexApiKey); | |
} | |
}, | |
}); | |
}); | |
}).catch(e => { | |
debug(e, '- falling back to Holodex login'); | |
// Not using hidden iframe technique due to potentially needing user input (to login) | |
// and to avoid potential CORS restrictions. | |
// let iframe = null; | |
let holodexLoginTab = null; | |
let holodexApiKeyListener = null; | |
return new Promise((resolve, reject) => { | |
//return reject('DEBUG TEST'); // test note: uncomment to debug user prompt fallback | |
// iframe = document.createElement('iframe'); | |
// iframe.src = 'https://holodex.net/login'; | |
// //iframe.referrerpolicy = 'no-referrer'; // doesn't work for bypassing CORS for document.cookie access | |
// // Ensure the Holodex iframe is hidden | |
// // display: none or visibility: hidden results in Holodex iframe's vue not properly loading; | |
// // workaround is to position the iframe behind existing body. | |
// //iframe.style.display = 'none'; | |
// //iframe.style.visibility = 'hidden'; | |
// iframe.style.position = 'fixed'; | |
// iframe.style.zIndex = -1; | |
// function onMessage(evt) { | |
// if (evt.origin !== 'https://holodex.net') { | |
// return; | |
// } | |
// window.removeEventListener('message', onMessage); | |
// debug('retrieved Holodex API key from iframe:', evt.data); | |
// return resolve(evt.data); | |
// } | |
// window.addEventListener('message', onMessage); | |
// document.body.appendChild(iframe); | |
// debug('inserted temp Holodex login iframe'); | |
//setTimeout(() => reject('timed out waiting for Holodex API key from iframe'), 2000); | |
holodexApiKeyListener = GM_addValueChangeListener('holodexApiKey', (key, oldValue, value, remote) => { | |
debug(key, 'changed from', oldValue, 'to', value); | |
if (!value) { | |
reject(key, 'is unexpectedly', value); | |
} else { | |
resolve(value); | |
} | |
}); | |
holodexLoginTab = GM_openInTab('https://holodex.net/login', false); | |
holodexLoginTab.onclose = function(evt) { | |
reject('Holodex login window closed without fetching API key'); | |
}; | |
debug('Holodex login tab opened:', holodexLoginTab); | |
}).finally(() => { | |
// if (iframe) { | |
// iframe.remove(); | |
// debug('removed temp Holodex login iframe'); | |
// } | |
if (holodexApiKeyListener) { | |
GM_removeValueChangeListener(holodexApiKeyListener); | |
} | |
if (holodexLoginTab && !holodexLoginTab.closed) { | |
holodexLoginTab.onclose = null; | |
holodexLoginTab.close(); | |
debug('Holodex login window closed'); | |
} | |
}); | |
}).catch(e => { | |
debug(e, '- user prompt fallback'); | |
const holodexApiKey = prompt('Enter Holodex API key:'); | |
if (!holodexApiKey) { | |
throw 'User did not enter Holodex API key'; | |
} else { | |
return holodexApiKey; | |
} | |
}).then(holodexApiKey => { | |
if (!holodexApiKey) { | |
} | |
GM_setValue('holodexApiKey', holodexApiKey); | |
debug('Holodex API key:', holodexApiKey); | |
return holodexApiKey; | |
}).catch(e => { | |
alert(e); | |
throw e; | |
}); | |
} | |
// const holodexApiKey = GM_getValue('holodexApiKey'); | |
const holodexApiKeyPromise = getHolodexApiKey(); | |
//////// HASH CHANGE LISTENER //////// | |
// Holotools is an SPA that has routes URL hashes (#abc) to a virtual page without reloading the page. | |
// Leave status/channels "page" (https://hololive.jetri.co/#/status) untouched, i.e. don't redirect to Holodex APIs. | |
// This requires reloading the page whenever navigating to or from #/status. | |
// Unfortunately, there's no event that can reliably capture all changes to location.hash, | |
// due to history.pushState not firing an event (neither hashchange nor popstate), | |
// hence a hack to poll location.hash changes upon any DOM mutation that the host app performs alongside the hash change. | |
// Still use the hashchange event handler to listen to location property assignment. | |
function listenForHashChange(handler) { | |
let curHash = location.hash; | |
function pollingHashMonitor() { | |
new MutationObserver(mutations => { | |
const oldHash = curHash; | |
const newHash = location.hash; | |
if (oldHash !== newHash) { | |
//debug('polling detected location.hash change from %s to %s', oldHash, newHash); | |
curHash = newHash; | |
handler(oldHash, newHash); | |
} | |
}).observe(document.body, { | |
childList: true, | |
subtree: true, | |
}); | |
} | |
if (document.body) { | |
pollingHashMonitor(); | |
} else { | |
window.addEventListener('DOMContentLoaded', pollingHashMonitor); | |
} | |
window.addEventListener('hashchange', evt => { | |
const i = evt.oldURL.indexOf('#'); | |
const oldHash = i < 0 ? '' : evt.oldURL.substring(i); | |
const newHash = location.hash; | |
//debug('hashchange detected location.hash change from %s to %s', oldHash, newHash); | |
curHash = newHash; | |
handler(oldHash, newHash); | |
}); | |
} | |
listenForHashChange((oldHash, newHash) => { | |
//log('URL hash changed from %s to %s', oldHash, newHash); | |
if (oldHash === '#/status' || newHash === '#/status') { | |
location.reload(); | |
} | |
}); | |
if (location.hash === '#/status') { | |
log('Holotools fixes not applied to status page:', location.href); | |
return; | |
} | |
//////// HTTP REQUEST INTERCEPTOR //////// | |
// Extremely basic sscanf | |
// Parameters: | |
// - formatStr: format string, only supports: | |
// %%, %d, %s (matches regex /[^/?&.]+/ to match url path components & query parameters) | |
// trailing ... (format string only needs to match start of url) | |
// If doesn't include query string (no ?), allows optional ending / | |
// - searchStr: string to search | |
// Returns: [match for 1st format specifier, match for 2nd format specifier, ...] or null if no match found | |
var sscanfUrl = (() => { | |
const cache = new Map(); | |
const REGEX = 0; | |
const STRING = 1; | |
const FULLSTRING = 2; | |
return function sscanfUrl(formatStr, searchStr) { | |
let cached = cache.get(formatStr); | |
if (!cached) { | |
const cacheKey = formatStr; | |
const fullMatch = !formatStr.endsWith('...'); | |
if (!fullMatch) { | |
formatStr = formatStr.substring(0, formatStr.length - 3); | |
} | |
const appendOptSlash = formatStr.includes('?') && formatStr.at(-1) !== '/'; | |
if (formatStr.replaceAll('%%').includes('%')) { | |
formatStr = formatStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // from https://stackoverflow.com/a/6969486/344828 | |
const formatSpecifiers = []; | |
formatStr = '^' + formatStr.replace(/%./g, m => { | |
if (m === '%%') { | |
return '%'; | |
} else if (m === '%d') { | |
formatSpecifiers.push('d'); | |
return '(-?\\d+)'; | |
} else if (m === '%s') { | |
formatSpecifiers.push('s'); | |
return '([^/?&.]+)'; | |
} else { | |
throw Error(`format specifier '${m}' unsupported`); | |
} | |
}); | |
if (appendOptSlash) { | |
formatStr += '/?'; | |
} | |
if (fullMatch) { | |
formatStr += '$'; | |
} else { | |
formatStr += '(.*)$'; | |
} | |
cached = {type: REGEX, value: new RegExp(formatStr), formatSpecifiers}; | |
} else { | |
cached = {type: fullMatch ? FULLSTRING : STRING, value: formatStr, formatSpecifiers: null}; | |
} | |
debug('sscanfUrl caching for key', cacheKey, ':', cached); | |
cache.set(cacheKey, cached); | |
} | |
const value = cached.value; | |
switch (cached.type) { | |
case REGEX: { | |
const m = value.exec(searchStr); | |
if (m) { | |
return cached.formatSpecifiers.map((specifier, i) => { | |
const matched = m[i + 1]; | |
if (specifier === 'd') { | |
return Number.parseInt(matched); | |
} else { | |
return matched; | |
} | |
}); | |
} else { | |
return null; | |
} | |
} | |
case STRING: | |
if (searchStr.startsWith(value)) { | |
return [searchStr.slice(value.length)]; | |
} else { | |
return null; | |
} | |
case FULLSTRING: | |
return searchStr === value ? [] : null; | |
} | |
} | |
})(); | |
// Extremely basic sprintf, supporting: | |
// %%, %d, %s | |
function sprintf(formatStr, ...args) { | |
let i = 0; | |
let str = formatStr.replaceAll(/%./g, m => { | |
if (m === '%%') { | |
return '%'; | |
} else if (m === '%d') { | |
return Math.trunc(args[i++]); | |
} else if (m === '%s') { | |
return args[i++]; | |
} else { | |
throw Error(`format specifier '${m}' unsupported`); | |
} | |
}); | |
if (formatStr.endsWith('...')) { | |
str = formatStr.slice(0, -3) + args[i]; | |
} | |
return str; | |
} | |
// For debug logging: workaround for Chromium currently unable to show Headers contents in log. | |
if (DEBUG) { | |
Object.defineProperty(Headers.prototype, 'asMap', { | |
configurable: true, | |
enumerable: true, | |
get() { | |
return new Map(this); | |
}, | |
}); | |
} | |
// Allows interception of HTTP requests | |
// register method accepts a single configuration object with entries: | |
// - sourceUrlFormat: sscanfUrl format string for source URL (URL to intercept) | |
// - sourceUrlFormats: same as sourceUrlFormat except an array of such strings | |
// Either sourceUrlFormat or sourceUrlFormats must be specified. | |
// - destinationUrlFormat: sscanfUrl/sprintf format string for destination URL (URL to redirect to) | |
// - destinationUrlFormats: same as destinationUrlFormat except an array of such strings | |
// - transformRequest: function that transforms the request, returning one of: | |
// - string URL | |
// - Request | |
// - Request options object (2nd optional parameter of Request constructor) | |
// - Promise that resolves to one of the above | |
// and given parameters: | |
// - this: this configuration object | |
// - Request object | |
// - destinationUrlFormats if destinationUrlFormats is specified, else destinationUrlFormat | |
// - match for 1st format specifier in sourceUrlFormat | |
// - match for 2nd format specifier in sourceUrlFormat | |
// - ... | |
// If destinationUrlFormats is specified, transformRequest must also be specified. | |
// If destinationUrlFormat is specified instead, transformRequest can be omitted and defaults to sprintf. | |
// Note on proxying XMLHttpRequest (XHR): | |
// The actual XHR.open/setRequestHeader calls are delayed until XHR.send, | |
// which first calls transformRequest with a synthetic Request constructed from arguments passed to | |
// XHR.open/setRequestHeader (method, url, request headers). | |
// The returned URL/Request/Request-options are translated into arguments to XHR.open/setRequestHeader/send. | |
// If transformRequest returns a promise, async passed to XHR.open must be false, | |
// and the promise is asynchronously resolved before the actual XHR instance methods are called, | |
// and if the promise is rejected (i.e. throws), then an error event is dispatched to the XHR instance. | |
// - transformResponse: function that transforms the response text, presumably in the same format that the | |
// original request would've returned; parameters are: | |
// - this: this configuration object | |
// - response text for HTTP request to destination URL (XMLHttpRequest.responseText/Response.text()) | |
// - destination URL (XMLHttpRequest.responseURL/Response.url) | |
// - match for 1st format specifier in destinationUrlFormat | |
// - match for 2nd format specifier in destinationUrlFormat | |
// - ... | |
// TODO: Somehow transform Response object instead? It's an async object though | |
class HttpRequestInterceptor { | |
stats = null; | |
statsKeyFunc = HttpRequestInterceptor.defaultStatsKeyFunc; | |
#origDescs = null; | |
#sourceToConfig = new Map(); | |
#destToConfig = new Map(); | |
// TODO: Make this private when TamperMonkey updates its ESLint to support it | |
static symRequestInfo = Symbol('requestInfo'); | |
constructor() { | |
//this.stats = new Map(); // uncomment to record stats | |
} | |
register(config) { | |
debug('HttpRequestInterceptor.register:', config); | |
if (!config.sourceUrlFormat && !config.sourceUrlFormats) { | |
throw Error('either sourceUrlFormat or sourceUrlFormats must be specified'); | |
} | |
if (!config.transformRequest && config.destinationUrlFormats) { | |
throw Error('transformRequest must specified if destinationUrlFormats is specified'); | |
} | |
if (!config.transformResponse) { | |
throw Error('transformResponse must be specified'); | |
} | |
const destInfo = { | |
format: config.destinationUrlFormat, | |
formats: config.destinationUrlFormats, | |
transform: config.transformRequest, | |
}; | |
const sourceUrlFormats = config.sourceUrlFormats ?? [config.sourceUrlFormat]; | |
for (const sourceUrlFormat of sourceUrlFormats) { | |
this.#sourceToConfig.set(sourceUrlFormat, config); | |
} | |
const destUrlFormats = config.destinationUrlFormats ?? (config.destinationUrlFormat ? [config.destinationUrlFormat] : sourceUrlFormats); | |
for (const destUrlFormat of destUrlFormats) { | |
this.#destToConfig.set(destUrlFormat, config); | |
} | |
} | |
get enabled() { | |
return this.#origDescs !== null; | |
} | |
enable() { | |
if (this.enabled) { | |
return; | |
} | |
log('enabling HTTP request interception for url:', document.URL); | |
this.#origDescs = []; | |
// Capture orig methods needed for XHR.send proxy | |
const { open: origOpen, setRequestHeader: origSetRequestHeader } = XMLHttpRequest.prototype; | |
this.#proxyMethod(XMLHttpRequest.prototype, 'open', (origOpen, interceptor) => function open(method, url) { | |
debug('XMLHttpRequest.open', ...arguments); | |
for (const [sourceUrlFormat, config] of interceptor.#sourceToConfig) { | |
const matched = sscanfUrl(sourceUrlFormat, url); | |
if (matched) { | |
debug('matched', matched, 'for source URL format', sourceUrlFormat, 'and config', config); | |
this[HttpRequestInterceptor.symRequestInfo] = { | |
config, | |
openArgs: arguments, | |
matched, | |
headers: {}, | |
}; | |
// Don't call orig open (will be done in send proxy) | |
//debug('XHR.open delayed:', this[HttpRequestInterceptor.symRequestInfo]); | |
return; | |
} | |
} | |
origOpen.apply(this, arguments); | |
}); | |
this.#proxyMethod(XMLHttpRequest.prototype, 'setRequestHeader', (origSetRequestHeader) => function setRequestHeader(header, value) { | |
const info = this[HttpRequestInterceptor.symRequestInfo]; | |
if (info) { | |
// Don't call orig setRequestHeader (will be done in send proxy) | |
//debug('XHR.setRequestHeader delayed:', header, value); | |
info.headers[header] = value; | |
} else { | |
origSetRequestHeader.call(this, header, value); | |
} | |
}); | |
this.#proxyMethod(XMLHttpRequest.prototype, 'send', (origSend) => { | |
return function send(body) { | |
debug('XMLHttpRequest.send', ...arguments); | |
const xhr = this; // to avoid `this` in nested function below | |
const info = xhr[HttpRequestInterceptor.symRequestInfo]; | |
if (info) { | |
delete xhr[HttpRequestInterceptor.symRequestInfo]; // probably unnecessary, but ensures no memleak | |
//debug('XHR.open/setRequestHeader no longer delayed:', info); | |
const { config, openArgs } = info; | |
const request = new Request(openArgs[1], { | |
method: openArgs[0], | |
headers: info.headers, | |
credentials: xhr.withCredentials ? 'include' : 'same-origin', | |
// Note: there's no equivalent of no-cors mode for XHR | |
}); | |
const format = config.destinationUrlFormats ?? config.destinationUrlFormat ?? config.sourceUrlFormat; | |
const transformRequest = config.transformRequest ?? sprintf; | |
let dest = transformRequest.call(config, request, format, ...info.matched); | |
if (dest) { | |
function asyncError(error) { | |
const evt = new ProgressEvent('error'); | |
evt.detail = error; // custom property in case user needs to access the error | |
xhr.dispatchEvent(evt); | |
} | |
function sendRequest(dest) { | |
if (typeof(dest) === 'string') { | |
if (dest.url !== request.url) { | |
dest = new Request(dest, request); | |
} | |
} else if (dest instanceof Request) { | |
// Use dest as-is | |
} else if (dest !== null && typeof(dest) === 'object') { | |
if (typeof(dest.then) === 'function') { // Promise | |
if (openArgs[2] === false) { | |
throw new TypeError('dest must not be a Promise when async is false'); | |
} | |
return dest.then(sendRequest).catch(asyncError); | |
} | |
dest = new Request(dest.url, dest); | |
} else { | |
throw new TypeError( | |
'dest must be string, Request, Request options object, ' + | |
'or Promise that resolves to one of those'); | |
} | |
debug('redirecting', request, 'to', dest); | |
openArgs[0] = dest.method; | |
openArgs[1] = dest.url; | |
origOpen.apply(xhr, openArgs); | |
for (const [header, value] of dest.headers) { | |
origSetRequestHeader.call(xhr, header, value); | |
} | |
origSend.call(xhr, body); | |
} | |
return sendRequest(dest); | |
} | |
} | |
origSend.apply(xhr, arguments); | |
}; | |
}); | |
this.#proxyXhrResponseProperty('response'); | |
this.#proxyXhrResponseProperty('responseText'); | |
this.#proxyXhrResponseProperty('responseXML'); | |
this.#proxyMethod(window, 'fetch', (origFetch, interceptor) => function fetch(resource, options) { | |
debug('fetch', ...arguments); | |
const request = !(resource instanceof Request) || options ? new Request(resource, options) : resource; | |
for (const [sourceUrlFormat, config] of interceptor.#sourceToConfig) { | |
const matched = sscanfUrl(sourceUrlFormat, request.url); | |
debug('fetch match', sourceUrlFormat, request.url, matched); | |
if (matched) { | |
debug('matched', matched, 'for source URL format', sourceUrlFormat, 'and config', config); | |
const format = config.destinationUrlFormats ?? config.destinationUrlFormat ?? sourceUrlFormat; | |
const transformRequest = config.transformRequest ?? sprintf; | |
let dest = transformRequest.call(config, request, format, ...matched); | |
if (dest) { | |
return new Promise((resolve, reject) => { | |
function fetchRequest(dest) { | |
if (typeof(dest) === 'string') { | |
if (dest.url !== request.url) { | |
dest = new Request(dest, request); | |
} | |
} else if (dest instanceof Request) { | |
// Use dest as-is | |
} else if (dest !== null && typeof(dest) === 'object') { | |
if (typeof(dest.then) === 'function') { // Promise | |
return dest.then(fetchRequest).catch(reject); | |
} | |
dest = new Request(dest.url, dest); | |
} else { | |
return reject(new TypeError( | |
'dest must be string, Request, Request options object, ' + | |
'or Promise that resolves to one of those')); | |
} | |
debug('redirecting', request, 'to', dest); | |
resolve(origFetch.call(window, dest)); | |
} | |
fetchRequest(dest); | |
}); | |
} | |
break; | |
} | |
} | |
return origFetch.call(window, request); | |
}); | |
this.#proxyResponsePropertyStatsOnly('body', 'get'); // TODO: implement non-binary handler for body stream if necessary, responseType should be body properties | |
this.#proxyResponsePropertyStatsOnly('arrayBuffer'); // assumed to always be binary | |
this.#proxyResponsePropertyStatsOnly('blob'); // TODO: implement non-binary handler (blob.text) if necessary, responseType should be the blob properties | |
this.#proxyResponsePropertyStatsOnly('formData'); // TODO: implement non-binary handler (non-file) if necessary | |
this.#proxyResponseProperty('json'); | |
this.#proxyResponseProperty('text'); | |
} | |
disable() { | |
if (this.enabled) { | |
log('disabling HTTP request interception for url:', document.URL); | |
for (const [obj, prop, origDesc] of this.#origDescs) { | |
Object.defineProperty(obj, prop, origDesc); | |
} | |
this.#origDescs = null; | |
} | |
} | |
#proxyMethod(obj, prop, defineFunc) { | |
return this.#proxyProperty(obj, prop, 'value', defineFunc); | |
} | |
#proxyGetter(obj, prop, defineFunc) { | |
return this.#proxyProperty(obj, prop, 'get', defineFunc); | |
} | |
#proxySetter(obj, prop, defineFunc) { | |
return this.#proxyProperty(obj, prop, 'set', defineFunc); | |
} | |
#proxyProperty(obj, prop, descKey, defineFunc) { | |
const origDesc = Object.getOwnPropertyDescriptor(obj, prop); | |
if (!origDesc) { | |
return warn('could not find', prop, 'on', obj); | |
} | |
// XXX: Possible bug in TamperMonkey where for native methods of window object, the following are the same: | |
// Object.getOwnPropertyDescriptor(window, prop).value | |
// Object.getOwnPropertyDescriptor(unsafeWindow, prop).value | |
// unsafeWindow[prop] | |
// which can result in "TypeError: Failed to execute '<method>' on 'Window': Illegal Invocation" | |
// when invoking unsafeWindow[prop].call(window, ...) | |
// for certain native methods and certain arguments (e.g. window.fetch with Request argument). | |
// Workaround is to special-case window methods to just use window[prop] | |
// instead of Object.getOwnPropertyDescriptor(window, prop).value. | |
const origFunc = obj === window && descKey === 'value' ? obj[prop] : origDesc[descKey]; | |
if (!origFunc) { | |
return warn('could not find', descKey, 'for', prop, 'on', obj); | |
} | |
this.#origDescs.push([obj, prop, origDesc]); | |
const func = defineFunc(origFunc, this); | |
if (func.name !== origFunc.name) { | |
Object.defineProperty(func, 'name', { value: origFunc.name }); // for console debugging purposes | |
} | |
Object.defineProperty(obj, prop, { | |
...origDesc, | |
[descKey]: func, | |
}); | |
} | |
#handleResponse(url, srcMethod, response, responseType, contentType) { | |
debug('handling intercepted HTTP request:\n', url, srcMethod, responseType, contentType, '\nresponse:\n', response); | |
if (this.stats) { | |
const statsKey = this.statsKeyFunc(url, srcMethod, responseType, contentType); | |
this.stats.set(statsKey, (this.stats.get(statsKey) || 0) + 1); | |
} | |
// TODO: handle json, document (XHR-only), formData (fetch-only) | |
if (responseType !== 'text') { | |
debug('not applying response handler for non-text responseType'); | |
return response; | |
} | |
for (const [destUrlFormat, config] of this.#destToConfig) { | |
const m = sscanfUrl(destUrlFormat, url); | |
if (m) { | |
//debug(response handler for', url, ':', config.transformResponse); | |
if (config.transformResponse) { | |
return config.transformResponse(response, url, ...m); | |
} | |
break; | |
} | |
} | |
return response; | |
} | |
#proxyXhrResponseProperty(responseProp) { | |
this.#proxyGetter(XMLHttpRequest.prototype, responseProp, (origGetter, interceptor) => function() { | |
const srcMethod = 'XMLHttpRequest.' + responseProp; | |
debug(srcMethod, 'for', this); | |
return interceptor.#handleResponse(this.responseURL, srcMethod, origGetter.call(this), this.responseType || 'text', | |
this.getResponseHeader('content-type')); | |
}); | |
} | |
#proxyResponseProperty(responseProp, descKey='value') { | |
this.#proxyProperty(Response.prototype, responseProp, descKey, (origGetter, interceptor) => function() { | |
debug('fetch', responseProp, 'for', this); | |
return origGetter.call(this).then((response) => { | |
return interceptor.#handleResponse(this.url, 'fetch', response, responseProp, this.headers.get('content-type')); | |
}); | |
}); | |
} | |
#proxyResponsePropertyStatsOnly(responseProp, descKey='value') { | |
if (this.stats) { | |
this.#proxyProperty(Response.prototype, responseProp, descKey, (origGetter, interceptor) => function() { | |
const statsKey = interceptor.statsKeyFunc(this.url, 'fetch', responseProp, this.headers.get('content-type')); | |
interceptor.stats.set(statsKey, (interceptor.stats.get(statsKey) || 0) + 1); | |
return origGetter.call(this); | |
}); | |
} | |
} | |
static defaultStatsKeyFunc(url, responseProp, responseType, contentType) { | |
const protocolIdx = url.indexOf('://'); | |
if (protocolIdx !== -1) { | |
url = url.substring(protocolIdx + 3); | |
} | |
const queryIdx = url.indexOf('?'); | |
if (queryIdx !== -1) { | |
url = url.substring(0, queryIdx); | |
} | |
if (contentType) { | |
const mimeParamIdx = contentType.indexOf(';'); | |
if (mimeParamIdx !== -1) { | |
contentType = contentType.substring(0, mimeParamIdx); | |
} | |
} | |
if (responseType) { | |
const mimeParamIdx = responseType.indexOf(';'); | |
if (mimeParamIdx !== -1) { | |
responseType = responseType.substring(0, mimeParamIdx); | |
} | |
} | |
if (contentType !== responseType) { | |
responseType += '(' + contentType + ')'; | |
} | |
return url + ',' + responseProp + ',' + responseType; | |
} | |
} | |
//////// HOLOTOOLS API => HOLODEX API //////// | |
// Blacklisted channels that are excluded for consideration as both direct streams and mentions of streams. | |
// There's no UI for this; must be manually set in the script's storage options. | |
// TODO: implement whitelist too? | |
const channelBlacklist = new Set(GM_getValue('channelBlacklist', [])); | |
log('blacklisted channel ids:', channelBlacklist); | |
const allChannels = new Map(); // channel id => holotools channel format | |
const nonBlacklistedHoloChannels = new Set(); | |
let joinedChannelIds = null; // "channel1,channel2,..." or null if needs to be recomputed (lazily computed) | |
function holodexToHolotoolsVideo(video) { | |
if (channelBlacklist.has(video.channel.id)) { | |
debug('excluding video on blacklisted channel:', video); | |
return null; | |
} | |
// TODO: update tooltip to include mentions somehow? via __vue__ property? | |
// Even if not on a non-blacklisted main channel, if the mentions don't include any non-blacklisted channel, exclude. | |
if (video.mentions && !nonBlacklistedHoloChannels.has(video.channel.id)) { | |
let anyHoloMention = false; | |
for (let mentionedChannel of video.mentions) { | |
if (nonBlacklistedHoloChannels.has(mentionedChannel.id)) { | |
anyHoloMention = true; | |
break; | |
} | |
} | |
if (!anyHoloMention) { | |
debug('excluding non-holo video without any holo mentions:', video); | |
return null; | |
} | |
} | |
const channel = holodexToHolotoolsChannel(video.channel); | |
return { | |
id: video.id, // used for uniqueness checks | |
yt_video_key: video.id, | |
title: video.title, | |
status: video.status, | |
// TODO: move out and only sort those with same live_schedule | |
// Streams are sorted by live_end then live_start then live_schedule. | |
// Of those, it's only likely for live_schedule to be the same across multiple upcoming streams, | |
// and since the sort is unstable, it leads to upcoming streams with the same scheduled time being ordered inconsistently. | |
// So we'll be adding a small channel-based offset at the sort's granularity (seconds) to ensure consistent ordering. | |
live_schedule: video.start_scheduled ? new Date(Date.parse(video.start_scheduled) + channel.ordinal * 1000).toISOString() : null, | |
live_start: video.start_actual ?? null, | |
live_end: video.end_actual ?? null, | |
live_viewers: video.live_viewers ?? null, | |
channel: channel, | |
}; | |
} | |
function holodexToHolotoolsChannel(channel, forceUpdate = false) { | |
const cachedChannel = allChannels.get(channel.id); | |
if (!forceUpdate && cachedChannel) { | |
return cachedChannel; | |
} | |
const holotoolsChannel = { | |
id: channel.id, // possibly unused | |
yt_channel_id: channel.id, | |
name: channel.english_name ?? channel.name, // channel.name is actual name, channel.english_name is manually translated name | |
description: channel.description ?? "", // omitted in holodex channels query, accessed but never used in holotools? | |
photo: channel.photo, | |
published_at: channel.published_at ?? "", // omitted in holodex channels query, only used in holotools status/channels page? | |
twitter_link: channel.twitter ?? "", | |
view_count: channel.view_count ?? 0, // omitted in holodex channels query, only used in holotools status/channels page? | |
subscriber_count: channel.subscriber_count ?? 0, // omitted in holodex videos query, only used in holotools status/channels page? | |
video_count: channel.video_count ?? 0, // omitted in holodex videos query, only used in holotools status/channels page? | |
video_original: channel.video_count ?? 0, // no equivalent in holodex, only used in holotools status/channels page? | |
}; | |
// ordinal is a custom field, used in ordering upcoming videos with the same scheduled time. | |
if (cachedChannel) { | |
debug('channel updated in cache:', holotoolsChannel); | |
holotoolsChannel.ordinal = cachedChannel.ordinal; | |
} else { | |
debug('channel added to cache:', holotoolsChannel); | |
holotoolsChannel.ordinal = allChannels.size; | |
} | |
if (!allChannels.has(channel.id)) { | |
allChannels.set(channel.id, holotoolsChannel); | |
joinedChannelIds = null; | |
} | |
return holotoolsChannel; | |
} | |
function jsonParse(str) { | |
try { | |
return JSON.parse(str); | |
} catch (e) { | |
throw Error('could not parse JSON from: ' + str); | |
} | |
} | |
const interceptor = new HttpRequestInterceptor(); | |
window.httpRequestInterceptor = interceptor; // for devtools console access | |
interceptor.register({ | |
sourceUrlFormats: [ | |
'https://api.holotools.app/v1/live?max_upcoming_hours=2190&hide_channel_desc=1', | |
'https://jetrico.sfo2.digitaloceanspaces.com/hololive/youtube.json' | |
], | |
destinationUrlFormat: 'https://holodex.net/api/v2/live?org=Hololive&include=mentions', | |
async transformRequest(request, holodexUrl) { | |
request.headers.set('X-APIKEY', await holodexApiKeyPromise); | |
// transformRequest(request, holodexUrl) { | |
// request.headers.set('X-APIKEY', holodexApiKey); | |
return new Request(holodexUrl, request); | |
}, | |
transformResponse(responseText) { | |
const holodexVideos = jsonParse(responseText); | |
debug('Holodex live format:', holodexVideos); | |
const holotoolsVideos = { | |
live: [], | |
upcoming: [], | |
past: [], | |
}; | |
const excludedVideos = new Set(); | |
for (const holodexVideo of holodexVideos) { | |
// TODO: for temp channels, filter for only added video ids | |
const holotoolsVideo = holodexToHolotoolsVideo(holodexVideo); | |
if (holotoolsVideo) { | |
holotoolsVideos[holodexVideo.status].push(holotoolsVideo); | |
} else { | |
excludedVideos.add(holodexVideo.id); | |
} | |
} | |
if (excludedVideos.size > 0) { | |
debug('excluded videos from blacklisted channels:', excludedVideos); | |
} | |
holotoolsVideos.ended = holotoolsVideos.past; | |
delete holotoolsVideos.past; | |
debug('converted to Holotools live format:', holotoolsVideos); | |
return JSON.stringify(holotoolsVideos); | |
} | |
}); | |
let isFirstChannelsQuery = true; | |
let origChannelBlacklist = new Set(); | |
interceptor.register({ | |
sourceUrlFormat: 'https://api.holotools.app/v1/channels?offset=%d&limit=%d', | |
destinationUrlFormat: 'https://holodex.net/api/v2/channels?type=vtuber&org=Hololive&sort=published_at&offset=%d&limit=%d', | |
async transformRequest(request, holodexUrlFormat, offset, limit) { | |
request.headers.set('X-APIKEY', await holodexApiKeyPromise); | |
// transformRequest(request, holodexUrlFormat, offset, limit) { | |
// request.headers.set('X-APIKEY', holodexApiKey); | |
if (isFirstChannelsQuery) { | |
isFirstChannelsQuery = false; | |
if (offset !== 0) { | |
// Assume that holotools v1 channels queries always return the following YT channel ids in order | |
origChannelBlacklist = new Set([ | |
"UCD8HOxPs4Xvsm8H0ZxXGiBw", | |
"UCa9Y57gfeY0Zro_noHRVrnw", | |
"UCwL7dgTxKo8Y4RFIKWaf8gA", | |
"UChAnqc_AY5_I3Px5dig3X1Q", | |
"UC0TXe_LYZ4scaW2XMyi5_kw", | |
"UC1CfXB_kRs3C-zaeTG3oGyg", | |
"UCKeAhJvy8zgXWbh9duVjIaQ", | |
"UCsehvfwaWF6nWuFnXI0AqZQ", | |
"UCJFZiqLMntJufDCHc6bQixg", | |
"UCXTpFs_3PqI41qX2d9tL2Rw", | |
"UCp-5t9SrOQwXMU7iIjQfARg", | |
"UC-hM6YJuNYVAmUWxeIr9FeA", | |
"UCdn5BQ06XqgXoAxIhbqw5Rg", | |
"UCS9uQI-jC3DE0L4IpXyvr6w", | |
"UCHj_mh57PVMXhAUDphUQDFA", | |
"UCGNI4MENvnsymYjKiZwv9eg", | |
"UC9mf_ZVpouoILRY9NUIaK-w", | |
"UCEzsociuFqVwgZuMaZqaCsg", | |
"UCAoy6rzhSf4ydcYjJw3WoVg", | |
"UCANDOlYTJT7N5jlRC3zfzVA", | |
"UCLbtM3JZfRTg8v2KGag-RMw", | |
"UCP0BspO_AMEe3aQqqpo89Dg", | |
"UCvaTdHTWBGv3MKj3KVqJVCw", | |
"UCQ0UDLQCjY0rmuxCDE38FGg", | |
"UCqm3BQLlJfvkTsX_hvm0UmA", | |
"UCdyqAaZDKHXg4Ahi7VENThQ", | |
"UCl_gCybOJRIgOXw6Qb4qJzQ", | |
"UC6t3-_N8A6ME1JShZHHqOMw", | |
"UCZlDXzGoo7d44bwdNObFacg", | |
"UC1opHUrw8rvnsadT-iGp7Cg", | |
"UCOyYb1c43VlX9rc_lT6NKQw", | |
"UCFTLzh12_nrtzqBPsTCqenA", | |
"UC7fk0CB07ly8oSl0aqKkqFg", | |
"UC1suqwovbL1kzsoaZgFZLKg", | |
"UCp3tgHXw_HI0QMk1K8qh3gQ", | |
"UC1DCedRgGHBdm81E1llLhOQ", | |
"UCNVEsYbiZjH5QLmGeSgTSzg", | |
"UCvInZx9h3jC2JzsIzoOebWg", | |
"UCDqI2jOz0weumE8s7paEk6g", | |
"UChSvpZYRPh0FvG4SJGSga3g", | |
"UCp6993wxpyDPHUpavwDFqgg", | |
"UCZgOv3YDEs-ZnZWDYVwJdmA", | |
"UCvzGlP9oQwU--Y0r9id_jnA", | |
"UCCzUftO8KOVkV4wQG1vkUvg", | |
"UC1uv2Oq6kNxgATlCiez59hw", | |
"UC5CwaMl1eIgY8h02uZw7u8A", | |
"UCFKOVgVbGmX65RxO3EtH3iw", | |
"UCAWSyEs_Io8MtpY3m-zqILA", | |
"UCUKD-uaobj9jiqB-VXt71mA", | |
"UCgZuwn-O7Szh9cAgHqJ6vjw", | |
"UCK9V2B22uJYu3N7eR_BT9QA", | |
"UCgNVXGlZIFK96XdEY20sVjg", | |
"UCL_qhgtOy0dy1Agp8vkySQg", | |
"UCHsx4Hqa-1ORjQTh9TYDhww", | |
"UCMwGHR0BTZuLsmjY_NT5Pwg", | |
"UCoSrY_IQQVpmIRZ9Xf-y93g", | |
"UCyl1z3jo3XHR1riLFKG5UAg", | |
"UCotXwY6s8pWmuWd_snKYjhg", | |
"UCfrWoRGlawPQDQxxeIDRP0Q", | |
"UCWsfcksUUpoEvhia0_ut0bA", | |
"UCYz_5n-uDuChHtLo7My1HnQ", | |
"UC727SQYUvx5pDDGQpTICNWg", | |
"UChgTyjG-pdNvxxhdsXfHQ5Q", | |
"UC8rcEBzJSleTkf_-agPM20g", | |
"UCsUj0dszADCGbF3gNrQEuSQ", | |
"UCO_aKKYxn4tvrqPjcTzZ6EQ", | |
"UCmbs8T6MWqUHP1tIQvSgKrg", | |
"UC3n5uGu18FoCy23ggWWp8tA", | |
"UCgmPnx-EEeOrZSg5Tiw7ZRQ", | |
"UCENwRMx5Yh42zWpzURebzTw", | |
"UCs9_O1tRPMQTHQ-N_L6FU2g", | |
"UC6eWCld0KwmyHFbAqK3V-Rw", | |
"UCIBY1ollUsauvVi4hW4cumw", | |
"UC_vMYWcDjmfdpH6r4TTn1MQ", | |
].slice(0, limit)); | |
log('Holotools fixes injected too late - will exclude %d already-Holotools-fetched channels', origChannelBlacklist.size); | |
offset = 0; | |
limit = 100; | |
} | |
} | |
// return new Request(sprintf(holodexUrlFormat, offset, limit), request); | |
return sprintf(holodexUrlFormat, offset, limit); | |
}, | |
transformResponse(responseText, url, offset, limit) { | |
const holodexChannels = jsonParse(responseText); | |
debug('Holodex channels format:', holodexChannels); | |
const count = holodexChannels.length; | |
const holotoolsChannels = { | |
count: count, | |
// Holodex doesn't provide total, so if count == limit, can't tell if we've actually reached the end, | |
// so trick holotools to fetch next batch by stating total > offset + limit | |
total: count < limit ? offset + count : offset + limit + 1, | |
channels: [], | |
}; | |
const origExcludedChannels = new Set(); | |
for (const channel of holodexChannels) { | |
const holotoolsChannel = holodexToHolotoolsChannel(channel, true); // called regardless of exclusion to populate cache | |
if (origChannelBlacklist.has(channel.id)) { | |
origExcludedChannels.add(channel.id); | |
} else { | |
if (!channelBlacklist.has(channel.id)) { | |
nonBlacklistedHoloChannels.add(channel.id); | |
} | |
holotoolsChannels.channels.push(holotoolsChannel); | |
} | |
} | |
if (DEBUG) { | |
debug('converted to Holotools channels format:', window.structuredClone(holotoolsChannels)); | |
} | |
if (origChannelBlacklist.size > 0) { | |
debug('excluded already-Holotools-fetched channels:', origExcludedChannels); | |
origChannelBlacklist.clear(); | |
} | |
log('non-blacklisted holo channel ids:', window.structuredClone(nonBlacklistedHoloChannels)); | |
return JSON.stringify(holotoolsChannels); | |
} | |
}); | |
interceptor.enable(); | |
//////// HOLOTOOLS UI IMPROVEMENTS //////// | |
if (location.hash.startsWith('#/watch')) { | |
function waitUntilElement(selector, root) { | |
root ??= document; | |
return new Promise(resolve => { | |
const element = root.querySelector(selector); | |
if (element) { | |
return resolve(element); | |
} | |
new MutationObserver((records, observer) => { | |
const element = root.querySelector(selector); | |
if (element) { | |
observer.disconnect(); | |
return resolve(element); | |
} | |
}).observe(root, { | |
childList: true, | |
subtree: true, | |
}); | |
}); | |
} | |
// Fade out already added videos in the dialog for adding videos. | |
document.addEventListener('DOMContentLoaded', () => { | |
const style = document.createElement('style'); | |
style.textContent = ` | |
.fadeout { | |
filter: grayscale(1); | |
opacity: 0.3; | |
} | |
`; | |
document.head.appendChild(style); | |
function processDialogVideos(ancestor) { | |
for (let videoImg of ancestor.querySelectorAll(':scope .video-image > img')) { | |
let m = /^https:\/\/i.ytimg.com\/vi\/([A-Za-z0-9_-]{11})\//.exec(videoImg.src); | |
if (m) { | |
let videoId = m[1]; | |
let videoPlayer = document.getElementById('player-' + videoId); | |
if (videoPlayer) { | |
videoImg.parentNode.parentNode.classList.add('fadeout'); | |
} | |
} | |
} | |
} | |
function observeDialogVideos(videoContainer) { | |
new MutationObserver((records) => { | |
for (let record of records) { | |
for (let node of record.addedNodes) { | |
if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('md-layout-item')) { | |
processDialogVideos(node); | |
} | |
} | |
} | |
}).observe(videoContainer, { | |
childList: true, | |
subtree: false, | |
}); | |
} | |
new MutationObserver((records) => { | |
for (let record of records) { | |
for (let node of record.addedNodes) { | |
if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('md-dialog-fullscreen')) { | |
let videoContainer = node.querySelector(':scope .md-layout'); | |
if (videoContainer) { | |
observeDialogVideos(videoContainer); | |
processDialogVideos(videoContainer); | |
} | |
} | |
} | |
} | |
}).observe(document.body, { | |
childList: true, | |
subtree: false, | |
}); | |
}); | |
// When mouse hovers over video player, highlight corresponding item in the top bar, and vice versa. | |
document.addEventListener('DOMContentLoaded', async () => { | |
const style = document.createElement('style'); | |
style.textContent = ` | |
.highlight-box::after { | |
content: ''; | |
display: block; | |
position: absolute; | |
top: 0; | |
left: 0; | |
height: 100%; | |
width: 100%; | |
/* border: 5px solid red; */ | |
box-shadow: inset 0 0 5px 5px red; | |
z-index: 2; | |
pointer-events: none; | |
} | |
`; | |
document.head.appendChild(style); | |
const [liveVideosContainer, playerContainer] = await Promise.all([ | |
waitUntilElement('div.live-videos', document.body), | |
waitUntilElement('div.player-container', document.body), | |
]); | |
debug('live-videos:', liveVideosContainer); | |
debug('player-container:', playerContainer); | |
function findLiveBox(target) { | |
while (target && target !== liveVideosContainer) { | |
if (target.tagName === 'DIV' && target.classList.contains('live-box')) { | |
return target; | |
} | |
target = target.parentNode; | |
} | |
return null; | |
} | |
const ytimgRegex = /^https:\/\/i\.ytimg\.com\/vi\/([A-Za-z0-9\-_]{11})\//; | |
function getVideoId(liveBox) { | |
const img = liveBox.querySelector(':scope > div.live-image > img'); | |
return ytimgRegex.exec(img?.src)?.[1] ?? null; | |
} | |
let currentFrame = null; | |
function highlightVideo(liveBox, debugContext) { | |
const id = getVideoId(liveBox); | |
if (id) { | |
const iframe = document.getElementById('player-' + id); | |
debug(debugContext, { | |
id, | |
liveBox, | |
iframe, | |
}); | |
if (currentFrame) { | |
currentFrame.classList.remove('highlight-box'); | |
} | |
if (iframe) { | |
currentFrame = iframe.parentNode; | |
currentFrame.classList.add('highlight-box'); | |
} | |
} | |
} | |
liveVideosContainer.addEventListener('mouseover', evt => { | |
const liveBox = findLiveBox(evt.target); | |
if (liveBox) { | |
// Ensure we're not just exiting and entering (or vice versa) elements of the same live box. | |
const relatedLiveBox = findLiveBox(evt.relatedTarget); | |
if (!relatedLiveBox || liveBox !== relatedLiveBox) { | |
highlightVideo(liveBox, evt.type); | |
} | |
} | |
}); | |
liveVideosContainer.addEventListener('mouseout', evt => { | |
if (!liveVideosContainer.contains(evt.relatedTarget)) { | |
debug(evt.type, 'top bar', liveVideosContainer); | |
if (currentFrame) { | |
currentFrame.classList.remove('highlight-box'); | |
currentFrame = null; | |
} | |
} | |
}); | |
liveVideosContainer.addEventListener('click', evt => { | |
// Need to wait a moment for vue to apply its changes. | |
setTimeout(() => { | |
const target = document.elementFromPoint(evt.clientX, evt.clientY); | |
const liveBox = findLiveBox(target); | |
if (liveBox) { | |
highlightVideo(liveBox, evt.type); | |
} | |
}, 0); | |
}, { | |
// Needed since existing click event listeners on X button stop propagation | |
capture: true | |
}); | |
let currentLiveBox = null; | |
function highlightLiveBox(iframe, debugContext) { | |
const id = iframe.id.substring('player-'.length); | |
const liveBox = liveVideosContainer.querySelector(`img[src^="https://i.ytimg.com/vi/${id}/"]`)?.closest('div.live-box'); | |
debug(debugContext, { | |
id, | |
iframe, | |
liveBox, | |
}); | |
if (currentLiveBox) { | |
currentLiveBox.classList.remove('highlight-box'); | |
} | |
if (liveBox) { | |
currentLiveBox = liveBox; | |
currentLiveBox.classList.add('highlight-box'); | |
} | |
} | |
playerContainer.addEventListener('mouseover', evt => { | |
const target = evt.target; | |
if (evt.target.tagName === 'IFRAME') { | |
highlightLiveBox(evt.target, evt.type); | |
} | |
}); | |
playerContainer.addEventListener('mouseout', evt => { | |
if (evt.target.tagName === 'IFRAME') { | |
debug(evt.type, evt.target); | |
if (currentLiveBox) { | |
currentLiveBox.classList.remove('highlight-box'); | |
currentLiveBox = null; | |
} | |
} | |
}); | |
}); | |
} | |
log('HoloTools fixes applied'); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment