Skip to content

Instantly share code, notes, and snippets.

@lbmaian
Last active May 6, 2026 11:41
Show Gist options
  • Select an option

  • Save lbmaian/fef815da2628dc6e546ba1f7cd8212a5 to your computer and use it in GitHub Desktop.

Select an option

Save lbmaian/fef815da2628dc6e546ba1f7cd8212a5 to your computer and use it in GitHub Desktop.
HoloTools Fixes and Enhancements
// ==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.9.2
// @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 = '[HoloTools Fixes and Enhancements]';
const console = {
...window.console,
debug(...args) {
//window.console.debug(logContext, ...args); // uncomment to disable debugging
},
log(...args) {
window.console.log(logContext, ...args);
},
warn(...args) {
window.console.warn(logContext, ...args);
},
error(...args) {
window.console.error(logContext, ...args);
},
};
function deepCopy(obj, visited = new Set()) {
if (obj === null || obj === undefined || visited.has(obj)) {
return obj;
}
if (typeof(obj) === 'object') {
visited.add(obj);
if (obj instanceof Node) {
return obj;
} else {
let copied;
if (obj instanceof Array || obj instanceof Set || obj instanceof Map) {
copied = new obj.constructor(Array.from(obj).map(x => deepCopy(x, visited)));
} else {
copied = Object.create(Object.getPrototypeOf(obj));
}
for (let [name, propDesc] of Object.entries(Object.getOwnPropertyDescriptors(obj))) {
if ('value' in propDesc) {
propDesc.value = deepCopy(propDesc.value, visited);
}
Object.defineProperty(copied, name, propDesc);
}
return copied;
}
} else {
return obj;
}
}
//////// 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.
// console.debug('holodex.net document.cookie:', document.cookie);
// console.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);
console.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 console.warn('could not find', prop, 'on', obj);
// }
// const origFunc = origDesc[descKey];
// if (!origFunc) {
// return console.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() {
// console.debug('document.hidden (orig):', orig.call(this));
// return true;
// });
// proxyProperty(Document.prototype, 'visibilityState', 'get', (orig) => function() {
// console.debug('document.visibilityState (orig):', orig.call(this));
// return 'visible';
// });
// proxyProperty(Document.prototype, 'hasFocus', 'value', (orig) => function hasFocus() {
// console.debug('document.hasFocus():', orig.call(this));
// return true;
// });
// proxyProperty(IntersectionObserverEntry.prototype, 'intersectionRatio', 'get', (orig) => function() {
// console.debug('IntersectionObserverEntry.intersectionRatio:', this, orig.call(this));
// return 1.0;
// });
// proxyProperty(IntersectionObserverEntry.prototype, 'isIntersecting', 'get', (orig) => function() {
// console.debug('IntersectionObserverEntry.isIntersecting:', this, orig.call(this));
// return true;
// });
// proxyProperty(IntersectionObserverEntry.prototype, 'isVisible', 'get', (orig) => function() {
// console.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) {
console.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);
}
console.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) {
console.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);
}
console.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 => {
console.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);
// console.debug('retrieved Holodex API key from iframe:', evt.data);
// return resolve(evt.data);
// }
// window.addEventListener('message', onMessage);
// document.body.appendChild(iframe);
// console.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) => {
console.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');
};
console.debug('Holodex login tab opened:', holodexLoginTab);
}).finally(() => {
// if (iframe) {
// iframe.remove();
// console.debug('removed temp Holodex login iframe');
// }
if (holodexApiKeyListener) {
GM_removeValueChangeListener(holodexApiKeyListener);
}
if (holodexLoginTab && !holodexLoginTab.closed) {
holodexLoginTab.onclose = null;
holodexLoginTab.close();
console.debug('Holodex login window closed');
}
});
}).catch(e => {
console.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);
console.debug('Holodex API key:', holodexApiKey);
return holodexApiKey;
}).catch(e => {
alert(e);
throw e;
});
}
// const holodexApiKey = GM_getValue('holodexApiKey');
const holodexApiKeyPromise = getHolodexApiKey();
//////// EVERYTHING BELOW HERE ASSUMES HOLOTOOLS PAGE ////////
//////// HASH CHANGE LISTENER ////////
// TODO: replace all this with some $router hook?
// HoloTools is an SPA that routes URL hashes (#abc) to a virtual page without reloading the page.
// Reload the page whenever we return to #/watch from a non-#/watch page.
// TODO: instead of reloading, reapply fixes/enhancements as needed? Would need to make them all idempotent
// 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 or back button,
// but also ensure the handler doesn't fire twice for the same hash change.
function listenForHashChange(handler) {
let curHash = location.hash;
function pollingHashMonitor() {
new MutationObserver(() => {
const oldHash = curHash;
const newHash = location.hash;
if (oldHash !== newHash) {
console.debug('polling detected location.hash change', oldHash, '=>', newHash, '- already current hash?', curHash === newHash);
if (curHash !== 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', function(evt) {
const i = evt.oldURL.indexOf('#');
const oldHash = i < 0 ? '' : evt.oldURL.substring(i);
const newHash = location.hash;
console.debug('hashchange detected location.hash change', oldHash, '=>', newHash, '- already current hash?', curHash === newHash);
if (curHash !== newHash) {
curHash = newHash;
handler(oldHash, newHash);
}
});
}
// Also update document title to include route path (and if route query has videos, # videos) in the URL hash.
function updateTitle() {
let m = /^#\/(\w+)?(?:\?videoId=([,A-Za-z0-9_-]+))?/.exec(location.hash);
if (m) {
let title = 'HoloTools';
if (m[1]) {
title += ` ${m[1][0].toUpperCase()}${m[1].substring(1)}`;
}
if (m[2]) {
title += ` (${m[2].split(',').length})`;
}
document.title = title;
}
}
listenForHashChange((oldHash, newHash) => {
updateTitle();
if (!oldHash.startsWith('#/watch') && location.hash.startsWith('#/watch')) {
console.warn('Reloading page', location.href, '...');
location.reload();
}
});
document.addEventListener('DOMContentLoaded', function() {
console.debug('DOMContentLoaded for hash:', location.hash);
updateTitle();
});
//////// 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};
}
console.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
// - onerror (XMLHttpRequest only):
// if specified and transformRequest succeeded, overrides the XMLHttpRequest instance's onerror
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) {
console.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;
}
console.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) {
console.debug('XMLHttpRequest.open', ...arguments);
for (const [sourceUrlFormat, config] of interceptor.#sourceToConfig) {
const matched = sscanfUrl(sourceUrlFormat, url);
if (matched) {
console.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)
//console.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)
//console.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) {
console.debug('XMLHttpRequest.send', ...arguments);
const xhr = this; // to avoid `this` in nested function below
xhr.timeout = 20000; // 20 second timeout
const info = xhr[HttpRequestInterceptor.symRequestInfo];
if (info) {
delete xhr[HttpRequestInterceptor.symRequestInfo]; // probably unnecessary, but ensures no memleak
//console.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) {
if (config.onerror) {
xhr.onerror = config.onerror;
}
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');
}
console.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) {
console.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);
console.debug('fetch match', sourceUrlFormat, request.url, matched);
if (matched) {
console.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) {
// TODO: somehow support onerror override which is specific to XHR for now?
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'));
}
console.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) {
console.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 console.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 console.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) {
console.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') {
console.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) {
//console.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;
console.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() {
console.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: use holotools own vue.$store.state.hideChannels instead?
// TODO: implement whitelist too?
const channelBlacklist = new Set(GM_getValue('channelBlacklist', []));
console.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)) {
console.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) {
console.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,
live_schedule: video.start_scheduled ?? 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 omitted in holodex channels query, accessed but never used in holotools?
// Instead, reusing for brief description of the channel: name (org)
description: `${channel.name} (${channel.org})`,
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 (in the below vue section),
// since Holodex's own ordering of them doesn't seem stable.
if (cachedChannel) {
console.debug('channel updated in cache:', holotoolsChannel);
holotoolsChannel.ordinal = cachedChannel.ordinal;
} else {
console.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);
}
}
function valueDesc(value) {
return {
value,
configurable: true,
enumerable: true,
}
}
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);
console.debug('Holodex live format:', holodexVideos);
const holotoolsVideos = {
live: [],
upcoming: [],
past: [], // Holodex query shouldn't return any past streams, but just in case
};
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) {
console.debug('excluded videos from blacklisted channels:', excludedVideos);
}
holotoolsVideos.ended = holotoolsVideos.past;
delete holotoolsVideos.past;
console.debug('converted to Holotools live format:', holotoolsVideos);
return JSON.stringify(holotoolsVideos);
},
onerror() {
// If the XHR fails, fall back to mocking empty lists of videos
const xhr = this;
console.warn('XHR failed - falling back to mocked empty lists of videos');
Object.defineProperties(xhr, {
readyState: valueDesc(4),
status: valueDesc(200),
statusText: valueDesc('OK'),
responseType: valueDesc('text'),
responseText: valueDesc(JSON.stringify({
live: [],
upcoming: [],
ended: [],
})),
});
xhr.onreadystatechange();
}
});
let isFirstChannelsQuery = true;
const 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));
console.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);
console.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) {
console.debug('converted to Holotools channels format:', window.structuredClone(holotoolsChannels));
}
if (origChannelBlacklist.size > 0) {
console.debug('excluded already-Holotools-fetched channels:', origExcludedChannels);
origChannelBlacklist.clear();
}
console.log('non-blacklisted holo channel ids:', window.structuredClone(nonBlacklistedHoloChannels));
return JSON.stringify(holotoolsChannels);
},
onerror() {
// If the XHR fails, fall back to mocking empty channels
const xhr = this;
console.warn('XHR failed - falling back to mocked empty channels');
Object.defineProperties(xhr, {
readyState: valueDesc(4),
status: valueDesc(200),
statusText: valueDesc('OK'),
responseType: valueDesc('text'),
responseText: valueDesc(JSON.stringify({
total: 0,
channels: [],
})),
});
xhr.onreadystatechange();
}
});
interceptor.enable();
//////// HOLOTOOLS SYNCHRONIZATION FIX ////////
// There's an unwanted "syncing" issue between multiple HoloTools tabs:
// Opening/closing a video in HoloTools tab A saves open video ids to the localStorage "watching" item,
// then new/reloaded HoloTools tab B reads that localStorage and prunes it to currently live streams.
// The localStorage "watching" value is used to handle soft navigation via hash routing from another HoloTools page to the HoloTools watch page.
// This also applies to other localStorage entries, but it's not as noticeable.
// Solution:
// - Patch localStorage to also write/read to sessionStorage (local to tab, survives reloads) first,
// such that effectively sessionStorage takes precedence, while localStorage is used as a fallback (in case of a new tab).
// - Combine all the separate localStoratge values into a monolithic value, to ensure atomicity when there are multiple HoloTools tabs.
// - Add a beforeunload handler, so that a new HoloTools tab uses the localStorage value from the last closed HoloTools tab.
// - This must be applied to every HoloTools page, not just the watch page.
// Furthermore, there's a subtle edge case that makes it hard to patch individual vue functions to temporarily patch localStorage methods.
// In the case of .container-watch's __vue__.updateHash which is responsible for setting localeStorage "watching":
// 1. User soft navigates to another page. This orphans our __vue__ which includes the patched updateHash.
// 2. User goes back in history to the watch page. This generates a new __vue__ that isn't patched (as we're going to reload soon).
// 3. Our hashchange event listener fires and starts a reload (simpler than trying to repatch while ensuring idempotency).
// 4. While unloading, HoloTools own popstate event listener fires, which ultimately calls the unpatched updateHash.
// The workaround is to keep tracking updates to __vue__ in order to reapply patches in that brief moment before reload...
// Or to just patch localStorage methods for the whole session, which although does away with the "safety" of more individual patching,
// is much easier than patching all the methods that will use localStorage.
const STATE_KEY = 'holotoolsState';
const STATE_PROPERTIES = [
'autoPlay',
'autoVolume',
'displayBilibili',
'displayTwitter',
'earlyStreams',
'frameGaps',
'hideChannels',
'lang',
'maxUpcomingSecs',
'otherStreamTime',
'otherStreams',
'ruleOnePlayer',
'rulePauseOther',
'theme',
'trackStreams',
'watchColumns',
'watching',
];
const origSetItem = localStorage.setItem;
const origGetItem = localStorage.getItem;
localStorage.setItem = function(name, value) {
if (!STATE_PROPERTIES.includes(name)) {
return origSetItem.call(this, name, value);
}
let stateJson = sessionStorage.getItem(STATE_KEY) ?? origGetItem.call(this, STATE_KEY);
let state = stateJson ? JSON.parse(stateJson) : {};
state[name] = value;
stateJson = JSON.stringify(state);
console.debug('setItem:', name, value, '=>', stateJson);
//console.trace('setItem', name, value, '=>', stateJson);
sessionStorage.setItem(STATE_KEY, stateJson);
origSetItem.call(this, STATE_KEY, stateJson);
// For compatibility/backup, continue also using original name/value in localStorage.
origSetItem.call(this, name, value);
};
localStorage.getItem = function(name) {
if (!STATE_PROPERTIES.includes(name)) {
return origGetItem.call(this, name);
}
let stateJson = sessionStorage.getItem(STATE_KEY) ?? origGetItem.call(this, STATE_KEY);
let value;
// HoloTools doesn't call localStorage.getItem often (only on startup?), so parsing the whole state JSON every time is fine.
if (stateJson) {
value = JSON.parse(stateJson)[name];
}
if (value === undefined) {
// For compatibility, continue also using original name in localStorage.
value = origGetItem.call(this, name);
} else if (value !== null) {
// If value isn't null (missing), then it must be a string (native setItem converts non-string values to strings).
value = String(value);
}
console.debug('getItem:', name, '=>', value);
return value;
};
window.addEventListener('beforeunload', function() {
let stateJson = sessionStorage.getItem(STATE_KEY);
if (stateJson) {
console.debug('beforeunload: dumping sessionStorage state to localStorage:', stateJson);
origSetItem.call(localStorage, STATE_KEY, stateJson);
}
});
//////// HOLOTOOLS HOOK MACHINERY ////////
let registeredSetups = new Set();
// Registers and returns a "setup" function that wraps the given function.
// The wrapper retains the (a)synchronicity (or rather then-ability) of the given function
// So async function => async wrapper, and sync function => sync wrapper.
// Each wrapper is stored in registeredSetups until the wrapper is called.
// This wrapper has a "done" property set to a Promise that resolves/rejects
// when the wrapper is called (if synchronous) or resolves (if thenable).
// This wrapped also has a "original" property set to the given function.
function registerSetup(func) {
const { promise, resolve, reject } = Promise.withResolvers();
promise.original = func;
function deregister() {
registeredSetups.delete(promise);
}
function setup(...args) {
try {
let value = func.apply(this, args);
if (value?.then) {
return value.finally(deregister).then(resolve, reject);
} else {
deregister();
resolve(value);
return value;
}
} catch (e) {
deregister();
reject(e);
throw e;
}
}
if (func.name) {
Object.defineProperty(setup, 'name', { configurable: true, value: func.name });
}
registeredSetups.add(setup);
setup.done = promise;
setup.original = func;
return setup;
}
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,
});
});
}
// $root, the root component, should be mounted on an .md-app element with the Vue Material library that HoloTools uses.
// Also because of that library, it's actually the first child of the root that contains the app-level methods like loadChannels,
// so will be assigning that child to $root rather than the actual root component.
let $root = document.querySelector('.md-app')?.__vue__;
// $router, also available on the shared prototype of any __vue__, maps routes to base component options and has routing hooks.
// $router.options.routes contains the path and base component options of each route,
// e.g. for the #/watch route, there's {path: '/watch', component: baseComponent}
// where baseComponent is the source definition of the route's base generated __vue__,
// such that properties of baseComponent are copied into the prototype of __vue__.$options
// (e.g. baseComponent.methods === generated __vue__.$options.methods), though the latter overrides and defines a few preoprties
// methods of its own, so they're not exactly the same.
let $router = $root?.$router;
// $sdk, HoloTools custom property available on the shared prototype of any __vue__, contains the methods that interact with web APIs,
// which methods like $root.loadChannels/refreshLives uses.
let $sdk = $root?.$sdk;
// One copied property of baseComponent is _scopeId, which is useful because the generated __vue__ doesn't seem to have a reference
// to the route itself (whether the path or baseComponent), and at least in HoloTools (and maybe other Vue 2 apps?),
// route base components have a unique _scopeId that seems to only be on the generated __vue__.$options._scopeId.
// When $router is available, we'll set up a bidirectional mapping between route _scopeId and path stripped of non-word characters.
const routeScopeIdToPath = {};
const routePathToScopeId = {};
function initRouteScopeIdPathMaps() {
//console.log('$router.options.routes:', $router.options.routes);
for (let route of $router.options.routes) {
const path = route.path.replace(/\W+/g, '');
const scopeId = route.component._scopeId;
routeScopeIdToPath[scopeId] = path;
routePathToScopeId[path] = scopeId;
}
//console.log('routeScopeIdToPath:', routeScopeIdToPath);
//console.log('routePathToScopeId:', routePathToScopeId);
}
if ($router) {
console.debug('__vue__.$router (startup):', $router);
initRouteScopeIdPathMaps();
}
// Each route's base components' __vue__ are stored in routeVues[routePath] where routePath stripped of non-word characters
// ("/watch" route's base component is mounted on .container-watch element, so when its __vue__ is set, it's stored in routeVues.watch).
// Special cases: "$root", "$router", "$sdk" map to their respective objects on the shared prototype of any __vue__.
const vues = {
$root,
$router,
$sdk,
};
// Same keys as vues, each mapping to a list of setup functions that's called with the corresponding vue object when it's mounted
// (either upon setup function registration if this script somehow runs late, or when __vue__ property is set to the vue object).
// Special cases: "$root", "$router", "$sdk" map to a list of setup functions that's called with the corresponding object
// upon first __vue__ detection below (or called upon setup function registration if that object is already available).
const vueSetups = {};
// Unless this script runs late, it's likely the above variables won't be populated, so we need to detect when they're available.
// HoloTools doesn't expose a global Vue object, so the best we can do is to synchronously detect when __vue__ is set,
// which is when a vue component instance is mounted on its target element.
// Specifically the order of operations during component initialization (still very simplified):
// 1. _init called:
// a. "beforeCreate" hooks called
// b. $options.inject processing (dependency injections)
// c. $options.props/methods binding to instance
// d. _data initialized with $options.data (calling it if its a function) and $options.props
// e. $options.computed/watch processing (reactivity setup)
// c. "created" hooks called
// 2. $mount called (usually at end of _init):
// a. "beforeMount" hooks called
// b. Initial _render called, which calls $options.render (which is typically compiled from template) to generate vnode tree
// c. Initial _update called:
// i. _vnode is set to that generated vnode tree (note: $vnode seems set during _render to parent component's _vnode)
// ii. generates HTML and recursively initializes components from vnode tree
// iii. actual mounting: $el is set to generated HTML, while __vue__ is set to component vue instance (we are here!)
// d. "mounted" hooks called
// 3. Rest of lifecycle, including "beforeUpdated" and "updated" hooks, then when deactivating, "beforeDestroy" and "destroyed" hooks
// So this interception happens too late for some use cases (like overriding $options.data/computed/watch)
// and an overriden $options.render may need to be manually triggered.
const symVueProp = Symbol('vue');
Object.defineProperty(HTMLElement.prototype, '__vue__', {
configurable: true,
enumerable: true,
get() {
return this[symVueProp];
},
set(value) {
if (value) {
//console.log('__vue__ set for', this, '\n', this.cloneNode(false).outerHTML, ...(this[symVueProp]?.$options?._scopeId ? ['\nexisting vue.$options._scopeId:', this[symVueProp]?.$options?._scopeId] : []));
if (!$root) {
vues.$root = $root = value.$root.$children[0];
console.debug('__vue__.$root.$children[0]:', $root);
vueSetups.$root?.forEach(func => func($root));
}
if (!$router) {
vues.$router = $router = value.$router;
console.debug('__vue__.$router:', $router);
initRouteScopeIdPathMaps();
vueSetups.$router?.forEach(func => func($router));
}
if (!$sdk) {
vues.$sdk = $sdk = value.$sdk;
console.debug('__vue__.$sdk:', $sdk);
vueSetups.$sdk?.forEach(func => func($sdk));
}
// Route base components have a $options._scopeId, which can be compared with $router.options.routes[i].component._scopeId.
// _scopeId is also stored as an attribute, but since descendant elements also have it, don't use this.hasAttribute(_scopeId)
// ($options._scopeId should only exist on base component's __vue__).
const routePath = routeScopeIdToPath[value.$options._scopeId];
if (routePath) {
if (this[symVueProp] !== value) {
this[symVueProp] = value;
console.debug(routePath, '__vue__:', value);
vues[routePath] = value;
vueSetups[routePath]?.forEach(func => func(value));
}
} else {
// Not a vue we care about, so just redefine regular writable value property on the instance to skip this getter/setter.
Object.defineProperty(this, '__vue__', {
configurable: true,
enumerable: true,
writable: true,
value,
});
}
}
},
});
// Registers a given func as a setup function that will be called when the routePath's corresponding base component's vue is mounted.
// If that vue is already mounted (uses $router to find _scopeId, then finds element with matching __vue__.$options._scopeId),
// ensures vues[routePath] is set to that vue object, and the given func will be called immediately.
// Special cases: if routePath is "$root", "$sdk", or "$router", given func is called when the corresponding object becomes available,
// or if it's already available, called immediately.
// In any case, the corresponding object is the sole argument passed to the func.
function registerVueSetup(routePath, func) {
const setupFunc = registerSetup(func);
if (routePath === '$root' || routePath === '$router' || routePath === '$sdk') {
const obj = vues[routePath];
if (obj) {
setupFunc(obj);
return setupFunc;
}
} else {
if ($router) {
const scopeId = routePathToScopeId[routePath];
if (!scopeId) {
throw new Error('could not find route for path: ' + routePath);
}
for (let candidate of document.querySelectorAll(`[${scopeId}]`)) {
const vue = candidate.__vue__;
if (vue?.$options?._scopeId === scopeId) {
vues[routePath] = vue;
setupFunc(vue);
return setupFunc;
}
}
}
}
vueSetups[routePath] ??= [];
vueSetups[routePath].push(setupFunc);
return setupFunc;
}
// All the following modify vue.$options prototype, which is shared between all instances of the target vue component.
// They may also do other modifications on the vue instance, depending on what's being overriden.
// Vue lifecycle hooks are functions in arrays on vue.$options prototype (and not in vue.$options.methods/props/etc.).
// For example, vue.$options.mounted is an array of functions that are called with the vue instance during mounting.
// This allows overriding a specific hook function by index, or adding a new hook function (pass null for index).
// The given hookProvider is called with the original hook function and must return a replacement function.
function defVueHook(vue, name, index, hookProvider) {
const hooks = vue.$options[name];
const origHook = index === null ? null : hooks[index];
const hook = { // trick to set function name
[name]: hookProvider(origHook)
}[name];
hooks[index === null ? hooks.length : index] = hook;
}
// Override vue.$options.render.
// Rendering is how vue generates a representation of a node tree from vue data called vnodes,
// to later be generated into HTML (including child components recursively) and merged onto any existing elements.
// The existing render may have already been called, but render is supposed many times due to reactivity anyway.
// Also, vue._render is generic and calls vue.$options.render and therefore does not need updating.
// The given renderProvider is called with the original render method and must return a replacement function.
function defVueRender(vue, renderProvider) {
const origRender = vue.$options.render;
const render = { // trick to set function name
render: renderProvider(origRender)
}.render;
vue.$options.render = render;
}
// Custom methods are defined in the methods object in vue.$options prototype, which are then bound as direct methods on vue
// during component instantiation (after "beforeCreate" hook and before "created"/"mounted"/etc. hooks).
// By the time our vue setups are called, component instance mounting is happening, so this binding's already occurred.
// Since instance operations/methods use bound methods rather than unbound ones, we also need to override the bound methods.
// However, we also need to override the $options.methods one in case the vue component is instantiated again.
// This happens when navigating from a non-watch page to a watch page, and even if the page is reloaded then, that reload isn't immediate.
// defVueMethod here is a convenience function that handles overriding a custom vue method on both the component definition and instance.
// The given methodProvider is called with the original unbound vue method and must return a replacement function.
function defVueMethod(vue, name, methodProvider) {
const origMethod = vue.$options.methods[name];
const method = { // trick to set function name
[name]: methodProvider(origMethod)
}[name];
vue.$options.methods[name] = method;
vue[name] = method.bind(vue);
}
// Computed property methods are defined in the computed object in vue.$options prototype, which are then processed into a watcher entry
// stored in both vue._computedWatchers/_watchers and get/set properties on vue during component instantiation
// (after "beforeCreate" hook and before "created"/"mounted"/etc. hooks).
// By the time our vue setups are called, component instance mounting is happening, so this processing's already occurred.
// Fortunately, the watcher and get/set property are generic, and only the watcher's getter property needs to be updated.
// The updated watcher is then triggered (typically run asynchronously), since vue's reactivity can't detect this change.
// The given computedProvider is called with the original computed property method and must return a replacement function.
function defVueComputed(vue, name, computedProvider) {
const origComputed = vue.$options.computed[name];
const computed = { // trick to set function name
[name]: computedProvider(origComputed)
}[name];
vue.$options.computed[name] = computed;
const watcher = vue._computedWatchers[name];
watcher.getter = computed;
// Don't need to update vue._watchers item since it shares same reference as vue._computedWatchers[name].
watcher.update();
}
// Watch methods are defined in the watch object in vue.$options prototype, which are then processed into a vue._watchers entry
// during component instantiation (after "beforeCreate" hook and before "created"/"mounted"/etc. hooks).
// By the time our vue setups are called, component instance mounting is happening, so this processing's already occurred.
// Fortunately, the watcher is generic, and only the watcher's cb property needs to be updated.
// The updated watcher is then triggered (typically run asynchronously), since vue's reactivity can't detect this change.
// The given watchProvider is called with the original watch method and must return a replacement function.
function defVueWatch(vue, name, watchProvider) {
const origWatch = vue.$options.watch[name];
const watch = { // trick to set function name
[name]: watchProvider(origWatch)
}[name];
vue.$options.watch[name] = watch;
// Not using vue.$watch method since it always adds a new entry to vue._watchers.
const watcher = vue._watchers.find(watcher => watcher.cb === origWatch);
watcher.cb = watch;
watcher.update();
}
// Registers setup func to run at DOMContentLoaded, or immediately if document is no longer in loading readyState.
function registerDOMReady(func) {
const setupFunc = registerSetup(func);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupFunc);
} else {
setupFunc();
}
return setupFunc;
}
//////// HOLOTOOLS MENU/ROUTING IMPROVEMENTS ////////
// Route paths that are supported - everything not here will be effectively removed.
// Notibly, these will not be supported: /ameliawatchon, /channels, /games, /holodex, /sC3h9MP2jDKsJ5gm, /songs, /twitter, /videos, /watchm
const routePathWhitelist = new Set([
'/',
'/help',
'/settings',
'/status', // "Channels" menu item routes to '/status', NOT '/channels' which also does exist and is unsupported
'/watch',
]);
// Remove navigation menu items to non-whitelisted route paths (like Songs => #/songs).
// The navigation menu is its own tree of components, but the rendering of which components is controlled directly by the app root vue.
registerVueSetup('$root', function($root) {
defVueRender($root, (origRender) => function() {
const mainVnode = origRender.call(this);
//console.log('root.render snapshot =>', deepCopy(mainVnode));
const appDrawerVnode = mainVnode.componentOptions.children.find(vnode => vnode.componentOptions?.tag === 'md-app-drawer');
//console.log('root.render AppDrawer snapshot =>', deepCopy(appDrawerVnode));
const menuListVnode = appDrawerVnode.componentOptions.children.find(vnode => vnode.componentOptions?.tag === 'md-list');
//console.log('root.render MenuList snapshot =>', deepCopy(menuListVnode));
// render is called multiple times as during initialization (including once before this override is applied),
// and only includes menu list when this.isDataReady is set to true after the end of the mounted hook (after first data fetch).
// So abort if the menu list isn't available.
if (!menuListVnode) {
return mainVnode;
}
// Find each found menu item with link we want to remove, then remove it along with the following spacing text.
// Searches backwards so children can be removed within the loop by index.
const menuItemVnodes = menuListVnode.componentOptions.children;
for (let i = menuItemVnodes.length - 1; i >= 0; i--) {
const link = menuItemVnodes[i].componentOptions?.propsData?.link;
if (typeof(link) === 'string' && !routePathWhitelist.has('/' + link)) {
console.debug('removing navigation menu item for route', link);
//console.log('removing root.render MenuItem for', link, 'at index', i, 'snapshot =>', deepCopy(menuItemVnodes[i]));
if (menuItemVnodes[i + 1]?.text?.trim() === '') {
//console.log('removing root.render MenuItem spacer for', link, 'at index', i + 1, 'snapshot =>', deepCopy(menuItemVnodes[i + 1]));
menuItemVnodes.splice(i, 2);
} else {
menuItemVnodes.splice(i, 1);
}
}
}
return mainVnode;
});
defVueWatch($root, 'currentRoute', (origCurrentRoute) => function() {
origCurrentRoute.call(this);
// Original logic: this.isToolbarClosable = true if this.currentRoute.path is one of: '/watch', '/ameliawatchon', '/sC3h9MP2jDKsJ5gm'
this.isToolbarClosable = this.currentRoute.path === '/watch';
});
defVueComputed($root, 'hasToolbar', (origHasToolbar) => function() {
// Original logic: return false if this.currentRoute.path is one of: '/watch', '/ameliawatchon', '/sC3h9MP2jDKsJ5gm'
return this.currentRoute.path !== '/watch';
});
defVueComputed($root, 'hasBottomBar', (origHasBottomBar) => function() {
// Original logic: return true if this.currentRoute.path is '/watchm'
return false;
});
// If current route is already a non-whitelisted one, then it's already been rendered before the overrides are applied,
// including below $router one, so when that $router override is applied, manually route again (to same path).
if (!routePathWhitelist.has($root.$route.path)) {
routerSetup.done.then(() => {
$root.$router.replace({});
});
}
});
// Override routing to non-whitelisted paths to behave like any actually unsupported route (displays effectively blank content).
// By the time either $root or $router hooks can be run (should be the same time in fact), $router.options.routes have already been processed,
// such that each base component's corresponding __vue__ has already been defined (but not yet mounted nor added to root vue tree)
// and the route matching itself is "compiled" into $router.matcher.
// So modifying $router.options.routes at any level doesn't help.
// Instead we can override $router.matcher.match so that the returned match for non-whitelasted paths looks like that of an unsupported route.
const routerSetup = registerVueSetup('$router', function($router) {
const origMatcherMatch = $router.matcher.match;
$router.matcher.match = function match(...args) {
const matched = origMatcherMatch.apply(this, args);
//console.log('$router.matcher.match', ...args, '=>', matched);
if (matched.matched.length && !routePathWhitelist.has(matched.path)) {
matched.matched.splice(0);
console.debug('removing matches for route path', matched.path);
}
return matched;
};
});
// TODO: replace above overly complicated HTTP interception logic with overriding $sdk.[get|getFromMirror]?
registerVueSetup('$sdk', function($sdk) {
});
Promise.all(Array.from(registeredSetups, setup => setup.done)).then(() => {
console.log('HoloTools app-level fixes and enhancements setup done');
});
//////// HOLOTOOLS WATCH UI IMPROVEMENTS ////////
let maxUpcomingSecs = localStorage.getItem('maxUpcomingSecs');
if (!maxUpcomingSecs) {
maxUpcomingSecs = 24 * 60 * 60;
localStorage.setItem('maxUpcomingSecs', maxUpcomingSecs); // default to 24 hours
}
registerVueSetup('watch', function(vue) {
// Watch page mounting reads comma-delimited video ids from the hash (#/watch?videoId=...), or from localStorage "watching" item as a fallback.
// Then it gradually adds (embeds) the videos one-by-one, each addition done in its own Promise that creates a new Promise for the next addition.
// Importantly, each video addition updates the hash/localStorage to the ids of the videos embedded so far, starting with the first read id.
// Therefore, if the page is closed/reloaded during this seconds-long asynchronous process, the hash/localStorage will have a truncated list of ids.
// Reloading immediately usually results in a single video id in the hash/localStorage, since HoloTools popstate handler runs any pending Promises.
// We want to avoid this hash/localStorage updating behavior on startup, both to avoid this problem and also to prevent unnecessary history clutter.
// Solution:
// 1. On mount, if hash doesn't have video ids, but localStorage "watching" item does, immediately and directly update hash to them.
// 2. Also on startup, treat the read video ids as "pending" (put in a vue data property for debugability, instead of a closure var).
// Note: it's too late to modify vue.$options.data which populates initial data properties as getters/setters.
// 3. The hash/localStorage video ids are unchanged as long as each newly added video matches the next pending video id,
// only updating if a video is removed, or the added video doesn't match the next pending video id.
// This means the hash/localStorage won't change as long as no other additions/removals occur while adding the pending videos,
// while seamlessly handing those edge cases.
// Specific algorithm: set hash/localStorage video ids to current added videos (this.watching keys) concat'd with pendingVideoIds,
// with the first of pendingVideoIds removed (dequeue pendingVideoIds) if it's the same as the last added video id (last this.watching key).
// TODO: Might be better to instead override addVideo to immediately append pending videos to this.added.
defVueHook(vue, 'mounted', 0, (origMounted) => function() {
//console.log('mounted: hash ids:', this.$route.query.videoId, 'localStorage ids:', localStorage.getItem('watching'));
let videoId = this.$route.query.videoId;
//console.log('mounted: location.hash:', location.hash);
if (!videoId) {
videoId = localStorage.getItem('watching');
if (videoId) {
// Synchronously update both location.hash and $router.query.videoId, the latter of which origOptionsMounted then reads.
// Using $router.replace to avoid creating new session history entry (analogous to history.replaceState).
this.$router.replace({ query: { videoId } });
//console.log('mounted: updated location.hash:', location.hash);
}
}
if (videoId) {
this.$data.pendingVideoIds = videoId.split(',');
}
origMounted.call(this);
});
defVueMethod(vue, 'updateHash', () => function() {
const watchingVideoIds = Object.keys(this.watching);
const pendingVideoIds = this.$data.pendingVideoIds;
//console.log('updateHash: watching ids:', watchingVideoIds, 'pending ids:', pendingVideoIds);
if (pendingVideoIds?.length) {
if (watchingVideoIds.at(-1) === pendingVideoIds[0]) {
const pendingVideoId = pendingVideoIds.shift();
//console.log('updateHash: dequeued first pending id:', pendingVideoId);
}
watchingVideoIds.push(...pendingVideoIds);
//console.log('updateHash: watching ids + pending ids:', watchingVideoIds);
}
// Original updateHash logic, tweaked to use above watchingVideoIds, and using history.replace instead of updating location
// to avoid creating new session history entry:
const joinedVideoIds = watchingVideoIds.join(',');
const newHash = joinedVideoIds ? '#/watch?videoId=' + joinedVideoIds : '#/watch';
if (location.hash !== newHash) {
//console.log('updateHash: location.hash:', location.hash, '=>', location.hash === newHash ? '(unchanged)' : newHash);
history.replaceState(null, '', newHash);
localStorage.setItem('watching', joinedVideoIds);
}
localStorage.setItem('watchColumns', this.columnCount);
});
// More permissive YouTube video ID extraction, accepting other YT URLs, non-YT URLs, and even the ID by itself.
defVueMethod(vue, 'extractYoutubeVideoId', () => function(str) {
let m = /(?<=[=\/?&#]|^)[A-Za-z0-9\-_]{11}(?=[=\/?&#]|$)/.exec(str.trim());
//console.debug('extractYoutubeVideoId:', str, '=>', m);
return m && m[0];
});
// Fixup iframe allow/allowFullscreen,src to approximately match Holodex's iframes
// TODO: match YT's own generated embed from context menu 'copy embed code'?
// `<iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
// Earliest hook after iframe creation is YouTube API's Player constructor, which we can override the reference to.
const origPlayer = vue.$store.state.ytiframe.Player;
vue.$store.state.ytiframe.Player = function(id, options) {
const videoId = id.substring('player-'.length);
const iframe = document.getElementById(id);
//console.debug('new Player into iframe', iframe);
iframe.allow = 'encrypted-media; clipboard-write; picture-in-picture';
iframe.allowFullscreen = true;
iframe.src = 'https://www.youtube.com/embed/' + videoId + '?autoplay=0&playsinline=1&cc_lang_pref=en&enablejsapi=1&widget_referrer=https%3A%2F%2Fholodex.net';
// This is also a convenient hook to adjust the just-created video frame component to ensure chat always opens in a popup.
const frameVue = iframe.parentNode.parentNode.__vue__;
if (frameVue) {
frameVue.minChatHeight = 99999;
frameVue.minChatWidth = 99999;
}
// Should only be called as constructor, so just return new orig Player.
return new origPlayer(id, options);
};
const style = document.createElement('style');
style.textContent = `
a.live-box {
display: block; /* original element is div which has this display */
}
/*
Original rule:
.live-list .live-videos .live-box .live-avatar.advanced[data-v-2989253e] {
border: 2px solid #b0b0b0;
}
New selector would be .live-box.known-upcoming .live-avatar, but omitting because this is barely noticeable
and now redundant with .live-box.known-upcoming .live-timer styling.
*/
/*
Original selector: .live-list .live-videos .live-box .live-avatar.watching[data-v-2989253e]
*/
.live-box.watching .live-avatar {
border: 2px solid #00ff00 !important;
box-shadow: 0px 0px 10px #00ff00 !important;
}
/*
Original rule:
.live-list .live-videos .live-box .live-image.fade[data-v-2989253e] {
filter: blur(1px)
}
Blur helped the channel avatar stand out and distinguish from videos without known channel, but IMO it's unnecessary.
*/
.live-box.has-channel .live-image {
filter: none;
}
/*
Original rule:
.live-list .live-videos .live-box .live-image.alphad[data-v-2989253e] {
opacity: 0.5;
}
New selector would be .live-box:not(.has-channel, .watching) .live-image,
but expanding this to no longer exclude .live-box.has-channel
while also applying to all non-button elements to be more prominent,
and reducing brightness rather than opacity (darkens without transparency).
*/
.live-box:not(.watching) *:is(.live-image, .live-avatar, .live-timer) {
filter: brightness(0.65);
}
/* Improve timer overlay */
.live-box .live-timer {
width: unset !important; /* original: 100% */
height: 17px !important; /* original: 20px */
position: absolute; /* unchanged */
bottom: 0px; /* unchanged */
left: unset !important; /* original: 0px */
right: 0px;
color: white; /* unchanged */
padding-left: 1px;
padding-right: 1px;
border-top-left-radius: 5px;
}
/*
Multidex's timer overlay background color is MUI red 500 for known live and MUI grey 500 for known upcoming,
Unlike HoloTools, Multidex removes its equivalent of top bar boxes when the video is embedded,
so known live is red because it's "urgent" to inform the user they're not watching a current stream.
HoloTools instead keeps top bar boxes for embedded videos (.watching), distinguishing them with a green live-avatar border
and non-faded background (see .live-box:not(.watching) rules above).
So we'll use a different color scheme that represents present/future/past events and avoids green.
Original background: linear-gradient(to top, rgba(0,0,0,0.8), rgba(0,0,0,0))
*/
.live-box.known-live .live-timer {
background: #1565c0b3 !important; /* MUI blue 800 with 70% alpha */
}
.live-box.known-upcoming .live-timer {
background: #ab47bcb3 !important; /* MUI purple 400 with 70% alpha */
}
.live-box.known-ended .live-timer {
background: #4e342eb3 !important; /* MUI brown 800 with 70% alpha */
}
/*
Allow multi-line tooltips in top bar, and make them more prominent.
TODO: Multi-line plain text is ugly and crude - info should be laid out in a preview box ala Multidex.
*/
.md-tooltip.live-tooltip {
white-space: pre-line; /* .md-tooltip original: nowrap */
height: auto; /* .md-tooltip original: 22px */
line-height: 18px; /* .md-tooltip original: 22px */
padding: 4px 8px; /* .md-tooltip original: 0 8px */
font-size: 14px; /* .md-tooltip original: 10px */
text-align: center;
}
`;
document.head.appendChild(style);
// Customize the rendering of the dynamic top bar of live/added streams (this.now items):
// That top bar itself isn't a standalone vue component; instead the main container re-renders nearly everything itself.
defVueRender(vue, (origRender) => function() {
const vue = this; // should be exact same as "global" vue, just making this vue more local to nested closures here
const mainVnode = origRender.call(vue);
//console.log('watch.render snapshot =>', deepCopy(mainVnode));
// Note: can't use elm property, since it's set after rendering.
const liveListVnode = mainVnode.children.find(vnode => vnode.data?.staticClass?.startsWith('live-list'));
//console.log('watch.render .live-list snapshot =>', deepCopy(liveListVnode));
const liveVideosVnode = liveListVnode.children.find(vnode => vnode.data?.class?.[1] === 'live-videos');
//console.log('watch.render .live-videos snapshot =>', deepCopy(liveVideosVnode));
for (let liveBoxVnode of liveVideosVnode.children) {
const liveImageVnode = liveBoxVnode.children.find(vnode => vnode.data?.class?.[0] === 'live-image');
//console.log('watch.render .live-image snapshot =>', deepCopy(liveImageVnode));
const videoId = liveImageVnode.children[0].data.attrs.src.match(/^https:\/\/i.ytimg.com\/vi\/([A-Za-z0-9_-]{11})\//)[1];
//console.log('watch.render .live-video snapshot for', videoId, '=>', deepCopy(liveBoxVnode));
// vue.now[videoId], which liveBoxVnode is being generated for, should be populated with:
// - known live: has channel/channelImg/channelName;
// changed in refreshList override to add advanced=true to add timer
// - early known upcoming: has channel/channelImg and advanced=true,
// changed in refreshList override to add channelName and new upcoming=true (since advanced=true is used for adding timer)
// - added/non-early known upcoming: originally only has id (videoId) and added=true;
// changed in refreshList override to add advanced=true, upcoming=true, and channel/channelImg/channelName
// - added (other, including formerly live or just unknown videos): only has id (videoId) and added=true; no channel info available
let video = vue.now[videoId];
// Change .live-video into a link.
liveBoxVnode.tag = 'a';
liveBoxVnode.data.attrs = {
href: 'https://www.youtube.com/watch?v=' + videoId,
};
// Rendering cases for each vue.now item:
// 1. known live: no remove button, blurred thumbnail, show channel avatar/tooltip, toggling embed changes avatar border
// 2. known early upcoming (if early streams enabled): same except also shows timer overlay
// 3. added videos that are already known live or early upcoming: same as 1/2
// (already handled in existing vue.addVideo by becoming live or early upcoming immediately)
// 4. added videos that are non-early known upcoming: show remove button and toggling embed fades thumbnail,
// but we want to change to show timer overlay, channel avatar/tooltip, and blurred thumbnail
// 5. added videos that become de facto known live or early upcoming: same as 4,
// but we want to convert to actual live or early upcoming (handled in below refreshList override)
// 6. other added videos: show remove button, toggling embed fades thumbnail, do NOT show upcoming timer or channel avatar/tooltip
// 7. known videos with added embed (in vue.watching) that have ended or become unknown: should be same as 6 but has edge cases
// (below refreshList override should eliminate those edge cases and make it always same as 6)
// Much of the above behavior is toggled on vue.now item (video here) properties and vue.watching item existence:
// video.added (true for added videos including both non-early known upcoming and unknown):
// - if false, don't blur thumbnail (.live-image.fade)
// - if true, tooltip shows id instead of channel name (should've been controlled by video.channel)
// video.advanced (originally true only to early known upcoming - below refreshList override will change this):
// - if true, show upcoming timer overlay (.live-timer created)
// - if true, change channel avatar border color (.live-avatar.advanced)
// video.channel:
// - if exists, show channel avatar (.live-avatar created)
// - if missing, show remove button (.live.remove created; should've been controlled by video.added)
// vue.watching[id] (if exists, video is embedded):
// - if exists, change channel avatar border color (.live-avatar.watching)
// - if exists, show open chat button (.live-chat created)
// vue.watching[id] && video.added (truthy if added video is embedded):
// - if false, fade thumbnail (.live-image.alphad, should've been controlled by video.channel)
// video.added=true and video.channel existence are apparently supposed to be mutually exclusive,
// assuming missing video.channel for video.added=true and vice versa.
// Revamp this such that the toggling style classes are on the .live-box itself, while tweaking behavior of above properties:
// video.added (applies to same videos as before):
// - no longer adds .fade on .live-image (video.channel now responsible for this)
// - no longer controls tooltip (now always shows id and channel name and title if available - see bottom of this method)
// - if true, now shows remove button (.live-remove created)
// video.advanced (below refreshList override changes this to essentially be the same as video.channel below):
// - if true, show duration/upcoming timer overlay (.live-timer created, unchanged other than applying to more videos)
// - no longer adds .advanced on .live-avatar (because both redundant and border color change is barely noticeable)
// video.channel (now exists for all known videos, including when they end and are moved to vue.added):
// - if exists, show channel avatar (.live-avatar created, unchanged other than applying to more videos)
// - if exists, blur thumbnail (.live-box.has-channel .live-image, formerly .live-image.fade controlled by video.added)
// - no longer controls remove button (video.added now responsible for this)
// video.knownState ('live' for known live, 'upcoming' for known upcoming, 'ended' for ended/privated known live/upcoming):
// - if available, change background color for timer overlay depending on value (.live-box.known-<knownState> .live-timer)
// vue.watching[id] (if exists, video is embedded):
// - if exists, change channel avatar border color (.live-box.watching .live-avatar, formerly .live-avatar.watching)
// - if exists, show open chat button (.live-chat created, unchanged)
// - if missing (and regardless of video.added and video.channel), fade thumbnail and channel avatar if it exists
// (.live-box:not(.watching) .live-image/.live-avatar, formerly .live-image.alphad partially controlled by video.added)
// - no longer adds .watching on .live-avatar or .alphad on .live-image
// Original logic: staticClass: 'md-layout-item live-box'
delete liveBoxVnode.data.staticClass;
liveBoxVnode.data.class = [
'md-layout-item',
'live-box',
video.added ? 'added-video' : '', // currently no style rules use this, but just in case
video.channel ? 'has-channel' : '',
video.knownState ? 'known-' + video.knownState : '',
vue.watching[videoId] ? 'watching' : '',
];
// Original logic: class: ["live-image", video.added ? "" : "fade", video.added && vue.watching[videoId] ? "" : "alphad"]
delete liveImageVnode.data.class;
liveImageVnode.data.staticClass = 'live-image';
// Original logic: class: ["live-avatar", vue.watching[videoId] ? "watching" : "", video.advanced ? "advanced" : ""]
const liveAvatarVnode = liveBoxVnode.children.find(vnode => vnode.data?.class?.[0] === 'live-avatar');
if (liveAvatarVnode) {
delete liveAvatarVnode.data.class;
liveAvatarVnode.data.staticClass = 'live-avatar';
// Existing logic uses vue.channelImg method to look up $store.state.channels for data fetched from the channel API.
// However, Holodex-fetched videos can include collab streams on channels that weren't fetched from the channel API.
// The channel data IS included in the fetched video data and below refreshList override populates the video with it.
// So if .live-avatar img src is empty, set it to video.channelImg.
const liveAvatarImgVnode = liveAvatarVnode.children.find(vnode => vnode.tag === 'img');
liveAvatarImgVnode.data.attrs.src ||= video.channelImg;
}
// Add remove button if needed (since was formerly controlled by video.channel existence rather than video.added).
if (video.added && !liveBoxVnode.children.find(vnode => vnode.data?.staticClass === 'live-remove')) {
// Original render is essentially a template compiled into nested calls of:
// - vue._c(tag[, data], children, flag): creates element/component vnode (don't know what flag exactly does)
// - vue._v(text): creates text vnode
// - vue._e(): creates comment vnode
// vue._c's parameters in particular are somewhat opaque, so just going to copy specific original render code.
// Conveniently, the original render substitutes an empty comment in place of the remove button.
liveBoxVnode.children[liveBoxVnode.children.findIndex(vnode => vnode.isComment)] =
vue._c('div', {
staticClass: 'live-remove',
on: {
click(evt) {
evt.stopPropagation();
vue.removeVideo(videoId);
},
},
}, [
vue._c('md-icon', [vue._v('cancel')])
], 1);
}
// Ensure left-clicking thumbnail or remove button or chat button doesn't actually navigate to the watch page.
function wrapOnclick(vnode) {
const origOnclick = vnode.data?.on?.click;
if (origOnclick) {
vnode.data.on.click = function(evt) {
// The on.click handler seems to only fire for left-click, so don't need to check mouse button.
evt.preventDefault();
return origOnclick.call(this, evt);
};
}
}
wrapOnclick(liveBoxVnode);
liveBoxVnode.children.forEach(wrapOnclick);
// Standardize tooltip to include both video id, title if available, and channel name/id if available,
// regardless of whether video is added/live/upcoming/whatever.
const tooltipVnode = liveBoxVnode.children.find(vnode => vnode.componentOptions?.tag === 'md-tooltip');
tooltipVnode.data.class = ['live-tooltip']; // md-tooltip and some other md-* classes are automatically added
let tooltipText = video.title ? `${video.title} [${videoId}]` : `[${videoId}]`;
if (video.channel) {
tooltipText = `${tooltipText}\n${video.channelName} [${video.channel}]`;
}
tooltipVnode.componentOptions.children[0].text = tooltipText;
}
return mainVnode;
});
// Customize which and the order of streams appearing in the top bar and "add video" dialog:
defVueMethod(vue, 'refreshList', (origRefreshList) => function() {
// Not sure if this.live.[live,upcoming] can ever be null/undefined, but original refreshList checks for it, so ensure they're initialized.
this.live ??= {};
this.live.live ??= [];
this.live.upcoming ??= [];
this.live.ended ??= []; // original refreshList ignores this, but just in case
let liveMap = new Map(this.live.live.map(video => [video.id, video]));
let upcomingMap = new Map(this.live.upcoming.map(video => [video.id, video]));
// Our "now" time is going to be slightly before refreshList's own "now", but it's good enough.
// TODO: allow customizing early stream threshold from 10 min?
const now = Date.now() / 1000;
const earlySecs = this.$store.state.earlyStreams ? 10 * 60 : 0;
// refreshList regenerates this.now (top bar) such that it merges together:
// 1. copies of known live (this.live.live)
// 2. if early streams enabled, copies of early known upcoming (this.live.upcoming with startTime < now + 10 min)
// 3. this.added entries which are just {id: video id, added: true} objects from the "add video" dialog (whether known upcoming or not)
// The latter takes precedence over the former, resulting in rendering differences (see notes in above render override).
// It also results in known ended (ended/privated known live/upcoming) videos that were added not moving to the end of the top bar,
// because only former this.live.live in this.now are appended to this.added, while original this.added items aren't moved.
// So whenever streams from this.added become live or early upcoming, remove from this.added so that it can't take precedence.
// Also, with our slightly earlier "now" time, our computed early upcoming are a strict subset of the actual computed early upcoming,
// which should ensure this doesn't inadvertently remove streams from the top bar.
for (let video of this.live.live) {
delete this.added[video.id];
}
if (earlySecs) {
let earlyTime = now + earlySecs;
for (let video of this.live.upcoming) {
if (parseInt(video.startTime) < earlyTime) {
delete this.added[video.id];
}
}
}
// Original refreshList appends videos from this.watching (embedded videos) that are no longer known live or early upcoming
// (including just-ended streams or videos that have become privated) to this.added via this.added[id] = this.watching[id].
// As part of this, it removes video.channel, which affects both this.added and this.watching due to the shared reference,
// presumably because video's channel info would be lost on reload anyway and/or to make it look like any other video in this.added.
// As this info is still useful and available, save this.watching's channels to be restored after the original refreshList call.
let watchingChannelMap = new Map(Object.values(this.watching).map(video => [video.id, video.channel]));
// Original refreshList ordering of generated this.now:
// 1. known live by actual start time (video.start), falling back to scheduled start time (video.startTime) if not actually live yet
// (ordering implicit from fetched Holodex data, while HoloTools puts upcoming videos with startTime>now into this.live)
// 2. early known upcoming by scheduled start time (ordering implicit from fetched Holodex data)
// 3. added videos (including known upcoming, known ended, or unknown) by insertion order
// This is potentially unstable because if videos can share the same start time, Holodex's ordering is not guaranteed to be stable.
// Note: while it's possible for a YT id to be a stringified integer and thus be ordered before other keys in this.now/this.added
// (a quirk of JS object's insertion order), this is exceedingly unlikely to happen (~10^-9%).
// We want to change this ordering to:
// 1. known live by actual start time, falling back to scheduled start time, secondarily by channel ordinal
// 2. early known upcoming by scheduled start time, secondarily by channel ordinal
// 3. added known upcoming by scheduled start time, secondarily by channel ordinal
// 4. other added videos by insertion order, including known ended and unknown
// This is done by sorting this.live.live, this.live.upcoming, and this.added (which still contains non-early known upcoming).
// It's fine if these are "permanently" updated (until this.live is refreshed from Holodex).
// Following are all in-place sorts (even the this.added object).
for (let video of this.live.live) {
const channel = allChannels.get(video.channel);
// HoloTools processLiveData confusingly puts both actually live videos (have video.start as actual start time and no video.startTime)
// AND upcoming live (have video.startTime as scheduled start time and no video.start) with startTime > now into this.live.live.
// This seems like a bug, since as far as I can tell, existing code assumes this.live.live videos have start rather than startTime
// (though that code doesn't seem to be used on the watch page).
// So default video.start to video.startTime.
video.start ??= video.startTime;
video.sortKey = parseInt(video.start) * 1000 + channel.ordinal; // assuming <1000 channels and keeping it integral
}
this.live.live.sort((a, b) => a.sortKey - b.sortKey);
for (let video of this.live.upcoming) {
const channel = allChannels.get(video.channel);
video.sortKey = parseInt(video.startTime) * 1000 + channel.ordinal; // assuming <1000 channels and keeping it integral
}
this.live.upcoming.sort((a, b) => a.sortKey - b.sortKey);
let upcomingAdded = [];
let otherAdded = [];
for (let video of Object.values(this.added)) {
const upcomingVideo = upcomingMap.get(video.id);
if (upcomingVideo) {
// this.added will still have its original items, just with startTime (just in case) and sortKey added.
video.startTime = upcomingVideo.startTime;
video.sortKey = upcomingVideo.sortKey;
upcomingAdded.push(video);
} else {
otherAdded.push(video);
}
delete this.added[video.id];
}
upcomingAdded.sort((a, b) => a.sortKey - b.sortKey);
for (let video of upcomingAdded.concat(otherAdded)) {
this.added[video.id] = video;
}
// Also change max upcoming limit from 6 hours to maxUpcomingSecs (which defaults to 24 hours):
// There's no direct way to change this limit other than hacking the startTime.
// - if startTime < now, change nothing (to keep it in upcoming)
// - else if startTime < 10 min from now, change nothing (since the min from now is displayed)
// - else, linearly compress startTime such that:
// startTime + maxUpcomingSecs becomes startTime + 6 hr
// startTime + 10 min remains startTime = 10 min
// Note that startTime is secs (NOT msecs) since Unix epoch, serialized as a string.
const timeRatio = (6 * 60 * 60 - earlySecs) / (maxUpcomingSecs - earlySecs);
const origUpcoming = this.live.upcoming;
try {
// The videos' properties are all getters, so copying them into plain objects with adjustments.
this.live.upcoming = origUpcoming.map((video, i) => {
const startTime = video.startTime;
const secs = parseInt(startTime) - now;
if (secs >= earlySecs) {
const adjustedSecs = earlySecs + (secs - earlySecs) * timeRatio;
const adjustedStartTime = Math.round(now + adjustedSecs);
video = {
...video,
startTime: String(adjustedStartTime), // refreshList still expects this to be a string
};
//console.debug(`live.upcoming[${i}]:`, startTime, '=>', adjustedStartTime, '|', 'now +', secs, '=>', 'now +', adjustedSecs);
}
return video;
});
// With all the above prep work done, finally call the original refreshList.
origRefreshList.call(this);
} finally {
// The adjusted videos will be copied into this.now (early) and this.later (non-early), but their startTime doesn't seem to be used later,
// so it doesn't need any further adjustment/restoration.
// However, this.live.upcoming will be used again in refreshList (and possibly elsewhere), so the above hack needs to be undone.
this.live.upcoming = origUpcoming;
}
// Original refreshList does its own sorting on non-early upcoming (which compares startTime as strings and ignores our channel ordinal),
// so reapply our own sort on them.
// (Early upcoming should still retain our sorting, specifically the order they appear in this.now.)
this.later.sort((a, b) => a.sortKey - b.sortKey);
// For any video in this.now that are known live/upcoming, update channel/title/remaining/advanced for consistent rendering
// (channel avatar, duration/upcoming timer, tooltip), specifically:
// - known live: already has channel/title, reuse remaining/advanced properties to show live streams' current duration
// - early known upcoming: already has channel/title, only case that originally has remaining/advanced set to display upcoming timer
// - added/non-early known upcoming: now treated same as early known upcoming, so fill in above properties
// - known ended (just-ended or privated, appended to this.added): retain properties to show channel and last known duration/upcoming timer
// - other added (unknown): still no channel/title/remaining/advanced available
// It's possible for an added formerly unknown video to be come known live/upcoming (Holodex can be slow to pick up streams),
// so ensure these videos have above properties are filled in. In fact, just ensure they're set for all known videos.
// Additionally, since we're abusing the advanced flag, which controls rendering of the timer overlay, to no longer just mean upcoming,
// we store the known live/upcoming/ended state in a new knownState property, which is used to style the timer overlay.
// Also video.channelImg/channelName seem unused (rendering uses vue.channelImg/channelName methods instead), but set them just in case.
for (let video of Object.values(this.now)) {
const upcomingVideo = upcomingMap.get(video.id);
if (upcomingVideo) {
const secs = Math.max(parseInt(upcomingVideo.startTime) - now, 0);
const hours = secs / (60 * 60);
// video.remaining's original format (for early upcoming only): "⏰ <minutes>" e.g. "⏰ 10"
// The ⏰ prefix is kept in text rather than a ::before CSS rule so that privated upcoming still show the ⏰.
video.remaining = '⏰' + (hours < 1 ? Math.floor(secs / 60) + 'm' : Math.floor(hours) + 'h');
video.advanced = true;
video.knownState = 'upcoming';
video.channel = upcomingVideo.channel; // channel id, not the full channel object
video.channelImg = upcomingVideo.channelImg;
video.channelName = allChannels.get(video.channel).name; // upcomingVideo.channelImg is missing for some reason
video.title = upcomingVideo.title;
} else {
const liveVideo = liveMap.get(video.id);
if (liveVideo) {
const secs = Math.max(now - parseInt(liveVideo.start), 0);
const hours = secs / (60 * 60);
// video.remaining/advanced originally never set for live videos, abusing them to show duration timer.
// Replacing the ⏰ prefix with a nbsp to help distinguish them from upcoming timers.
video.remaining = String.fromCharCode(0xA0) + (hours < 1 ? Math.floor(secs / 60) + 'm' : Math.floor(hours) + 'h');
video.advanced = true;
video.knownState = 'live';
video.channel = liveVideo.channel; // channel id, not the full channel object
video.channelImg = liveVideo.channelImg;
video.channelName = liveVideo.channelName;
video.title = liveVideo.title;
} else if (video.advanced) {
// If neither known live nor upcoming, and video.advanced (and video.remaining), infer video is a just-ended or privated stream
// with the duration/upcoming timer (and video.channel) still available.
video.knownState = 'ended';
// Keep channel properties since they're still useful (in fact, refreshList deletes video.channel, and we restore it below).
} // Else assume unknown video, so all the above properties are never set.
}
}
// this.watching (embedded videos) is only updated in this.embedVideo, which sets this.watching[id] to this.now[id],
// so when this.now is updated, this.watching still has the old this.now item.
// This matters because:
// 1. Above logic updates remaining/advanced in upcoming videos in this.now. If that video is from this.added, it's not a copy,
// so this.watching will reflect the updated item. If it's instead from this.live (currently live or early upcoming),
// then it's a copy that won't be reflected in this.watching.
// 2. However, the above logic also swaps this.now videos from this.added to a this.live copy when the video becomes live or early upcoming,
// so known upcoming videos in this.watching will eventually have stale added/remaining/advanced.
// 3. When a known live stream ends and it (or a stale copy) is in this.watching, original refreshList sets this.added[id] to this.watching[id]
// (and in turn, merged back into this.now), effectively moving the formerly live video to the end of the top bar as an added video.
// Thus this.added can inherit items with stale remaining/advanced values via this.watching.
// Fortunately refreshList does ensure that the added flag is set when this happens.
// Update this.watching with the latest this.now, so that future this.now won't get stale data from this.watching,
// while also restoring channel (see above about channel getting deleted).
for (let videoId in this.watching) {
// Post-merge this.now's keys (video ids) should be a superset of this.watching's keys.
// Also prefer channel that was populated above over the pre-merge saved channel, especially when the latter can be missing
// (this edge case can happen when Holodex is slow to pick up a live stream that's already added to this.watching,
// such that it initially doesn't have a channel and the above should populate channel when Holodex finally picks it up).
let video = this.now[videoId];
video.channel ??= watchingChannelMap.get(videoId);
this.watching[videoId] = video;
}
});
// There's no guarantee refreshList wasn't already called before above override such that it may be a while before it's called again,
// so call our overriden one immediately.
vue.refreshList();
});
// TODO: Should be able to revamp all this to vue overrides
if (location.hash.startsWith('#/watch')) {
// Improvements for "add video" dialog:
// - Fade out already added (embedded) videos.
// - Add upcoming timer overlay, add channel link, title overflow ellipsis, thumbnail links to YT watch page, increase max thumbnail size.
// - URL/ID text input autofocus & Enter key submit (URL/ID handling improvements in above vue hook).
// - Hide non-Hololive and non-manual-input sections which are non-functional anyway.
registerDOMReady(function() {
const style = document.createElement('style');
style.textContent = `
.fadeout {
filter: grayscale(1);
opacity: 0.3;
}
/* Note: .video-box .video-timer already used on HoloTools homepage */
.video-box .video-timer-overlay {
position: absolute;
bottom: 4px;
right: 4px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 5px;
font-size: 13px;
}
.video-box .video-image {
max-width: 240px; /* original: 192px */
}
.video-box .video-image img {
max-height: unset; /* original: 108px */
}
.video-box .video-title {
max-width: 240px; /* original: 200px */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
.video-box .video-channel {
/* Mostly copied from existing ".video-box .video-title" rule; only height and webkit-line-clamp is differnt */
width: 100%;
height: auto;
font-size: 14px;
line-height: 18px;
overflow: hidden;
max-width: 240px;
margin: 0px auto 5px;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
`;
document.head.appendChild(style);
function processDialogVideos(ancestor) {
const now = Date.now() / 1000;
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) {
const videoId = m[1];
// Fade out already added (embedded) videos.
const videoPlayer = document.getElementById('player-' + videoId);
if (videoPlayer) {
videoImg.parentNode.parentNode.classList.add('fadeout');
}
// Wrap thumbnail in a link to YT watch page.
const videoLink = document.createElement('a');
videoLink.href = 'https://www.youtube.com/watch?v=' + videoId;
videoImg.before(videoLink);
videoLink.append(videoImg);
// Ensure left-clicking thumbnail doesn't actually navigate to the YT watch page (so that it still embeds video in HoloTools).
videoLink.addEventListener('click', function(evt) {
if (evt.button === 0) {
evt.preventDefault();
}
});
// TODO: make this more like HoloTools homepage video-box? Though still keep timer overlay?
// vue should be guaranteed to be available by now.
const video = vues.watch.live.upcoming.find(video => video.id === videoId);
//console.log(videoId, ':', video);
if (video) {
const startTime = parseInt(video.startTime);
const secs = Math.max(startTime - now, 0);
const hours = secs / (60 * 60);
const timerText = hours < 2 ? Math.floor(secs / 60) + ' minutes' : Math.floor(hours) + ' hours';
//console.log(videoId, 'time:', new Date(startTime * 1000), '=>', timerText);
let timerOverlay = videoLink.parentNode.querySelector('.video-timer-overlay');
if (!timerOverlay) {
timerOverlay = document.createElement('div');
timerOverlay.classList.add('video-timer-overlay');
videoLink.after(timerOverlay);
}
timerOverlay.textContent = timerText;
const videoBox = videoLink.parentNode.parentNode;
const channelBox = document.createElement('div');
channelBox.classList.add('video-channel');
const channelLink = document.createElement('a');
channelLink.href = 'https://www.youtube.com/channel/' + video.channel;
channelLink.target = '_blank';
const channel = allChannels.get(video.channel);
channelLink.textContent = channel.name;
channelLink.title = channel.description;
channelBox.append(channelLink);
videoBox.append(channelBox);
}
}
}
}
function observeDialogVideos(videoContainer) {
new MutationObserver((records) => {
for (let record of records) {
for (let node of record.addedNodes) {
if (node.classList?.contains('md-layout-item')) {
processDialogVideos(node);
}
}
}
}).observe(videoContainer, {
childList: true,
subtree: false,
});
}
// There's no need to keep track of whether listeners/observers are already added here, since "add video" dialog is recreated every time it opens.
new MutationObserver((records) => {
for (let record of records) {
for (let node of record.addedNodes) {
if (node.classList?.contains('md-dialog-fullscreen')) {
const dialog = node;
const videoContainer = dialog.querySelector('.md-layout');
if (videoContainer) {
observeDialogVideos(videoContainer);
processDialogVideos(videoContainer);
}
// Enter key support on URL/ID text input.
const input = dialog.querySelector('input.input-search');
const inputContainer = input.parentElement.parentElement;
input.addEventListener('keydown', function(evt) {
if (evt.key === 'Enter') {
let addVideoButton = inputContainer.querySelector('button');
if (!addVideoButton) {
throw Error('could not find Add Video button');
}
addVideoButton.click();
}
});
// When the text input becomes visible in the future, autofocus it.
// Fortunately, HoloTools simply toggles inline style in the input container, so it's easy to observe.
function focusInputIfVisible() {
if (input.checkVisibility()) {
input.focus();
}
}
new MutationObserver(focusInputIfVisible).observe(inputContainer, {
attributes: true,
attributeFilter: ['style'],
});
// Also if the text input is already visible when dialog opens, focus it.
// For some reason, HoloTools focuses the dialog itself in a short setTimeout after opening,
// so need to focus the text input after that.
dialog.addEventListener('focusin', focusInputIfVisible);
// Also change menu item and label to indicate both YT URLs and IDs are accepted, and remove non-Hololive sections.
const menuItems = dialog.querySelectorAll('.md-list-item');
menuItems[0].querySelector('.md-list-item-text').textContent = 'URL or Video ID';
menuItems[2].remove(); // "Nijisanji"
menuItems[3].remove(); // "Other"
dialog.querySelector(`label[for="${input.id}"]`).textContent = 'URL or Video ID';
}
}
}
}).observe(document.body, {
childList: true,
subtree: false,
});
});
// When mouse hovers over video player, highlight corresponding item in the top bar, and vice versa.
registerDOMReady(async function() {
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 !important;
z-index: 2; /* necessary for the iframe highlight box */
}
`;
document.head.appendChild(style);
const [liveVideosContainer, playerContainer] = await Promise.all([
waitUntilElement('div.live-videos', document.body),
waitUntilElement('div.player-container', document.body),
]);
console.debug('live-videos:', liveVideosContainer);
console.debug('player-container:', playerContainer);
function findLiveBox(target) {
while (target && target !== liveVideosContainer) {
if (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 > .live-image > img');
return ytimgRegex.exec(img?.src)?.[1] ?? null;
}
let currentFrame = null;
function highlightVideo(liveBox, debugContext) {
const videoId = getVideoId(liveBox);
if (videoId) {
const iframe = document.getElementById('player-' + videoId);
console.debug(debugContext, {
videoId,
liveBox,
iframe,
});
if (currentFrame) {
currentFrame.classList.remove('highlight-box');
}
if (iframe) {
currentFrame = iframe.parentNode;
currentFrame.classList.add('highlight-box');
}
}
}
liveVideosContainer.addEventListener('mouseover', function(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', function(evt) {
if (!liveVideosContainer.contains(evt.relatedTarget)) {
console.debug(evt.type, 'top bar', liveVideosContainer);
if (currentFrame) {
currentFrame.classList.remove('highlight-box');
currentFrame = null;
}
}
});
// TODO: could replace this with an actual vue hook?
liveVideosContainer.addEventListener('click', function(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 videoId = iframe.id.substring('player-'.length);
const liveBox = liveVideosContainer.querySelector(`img[src^="https://i.ytimg.com/vi/${videoId}/"]`)?.closest('.live-box');
console.debug(debugContext, {
videoId,
iframe,
liveBox,
});
if (currentLiveBox) {
currentLiveBox.classList.remove('highlight-box');
}
if (liveBox) {
currentLiveBox = liveBox;
currentLiveBox.classList.add('highlight-box');
}
}
playerContainer.addEventListener('mouseover', function(evt) {
const target = evt.target;
if (evt.target.tagName === 'IFRAME') {
highlightLiveBox(evt.target, evt.type);
}
});
playerContainer.addEventListener('mouseout', function(evt) {
if (evt.target.tagName === 'IFRAME') {
console.debug(evt.type, evt.target);
if (currentLiveBox) {
currentLiveBox.classList.remove('highlight-box');
currentLiveBox = null;
}
}
});
});
Promise.all(Array.from(registeredSetups, setup => setup.done)).then(() => {
console.log('HoloTools watch page fixes and enhancements setup done');
});
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment