YouTube Helper API.
Dette script bør ikke installeres direkte. Det er et bibliotek, som andre scripts kan inkludere med metadirektivet // @require https://update.greasyfork.org/scripts/549881/1841778/YouTube%20Helper%20API.js
// ==UserScript==
// @name YouTube Helper API
// @author ElectroKnight22
// @namespace electroknight22_youtube_helper_api_namespace
// @version 1.1.1
// @license MIT
// @description A helper api for YouTube scripts that provides easy and consistent access for commonly needed functions, objects, and values. //TODO: UPDATE TO MATCH
// @note Various improvement suggestion courtesy of Alplox (https://github.com/Alplox) (https://greasyfork.org/en/users/1529381-alplox)
// ==/UserScript==
/*jshint esversion: 11 */
// eslint-disable-next-line no-unused-vars
const youtubeHelperApi = (() => {
'use strict';
// --- DEBUG SYSTEM ---
/**
* @typedef {Object} Debugger
* @property {boolean} enabled - Master switch
* @property {number} level - Current threshold (0-4)
* @property {Function} logMinimal - Level 0 (Green)
* @property {Function} logTypical - Level 1 (Blue)
* @property {Function} logDetailed - Level 2 (Cyan)
* @property {Function} logAll - Level 3 (Gray)
* @property {Function} logOverkill - Level 4 (Magenta)
* @property {Function[]} log - Numeric access [0-4]
*/
/** @type {Debugger} */
const debug = (() => {
const state = {
enabled: false,
level: 1,
badge: 'YT-Helper-API [init placeholder]',
levels: {
Minimal: { val: 0, color: '#28a745' },
Typical: { val: 1, color: '#007bff' },
Detailed: { val: 2, color: '#17a2b8' },
All: { val: 3, color: '#6c757d' },
Overkill: { val: 4, color: '#ce00a8' },
},
};
const debugApi = {
get enabled() {
return state.enabled;
},
set enabled(v) {
state.enabled = !!v;
debugApi.rebuild();
},
get level() {
return state.level;
},
set level(v) {
if (typeof v === 'string') {
const match = Object.entries(state.levels).find(([k]) => k.toLowerCase() === v.toLowerCase());
state.level = match ? match[1].val : 1;
} else {
state.level = v;
}
debugApi.rebuild();
},
channels: {
_registry: new Map(),
_subscribed: new Set(),
get subscribed() {
return Array.from(this._subscribed);
},
get registered() {
return Array.from(this._registry.keys());
},
add(channelName) {
const id = channelName.toLowerCase();
if (this._registry.has(id)) return;
this._subscribed.add(id);
const isActive = () => this._subscribed.has(id);
const createLogger = (levelName) => (...args) => {
if (isActive() && debugApi[`log${levelName}`]) {
debugApi[`log${levelName}`](`[${channelName}]`, ...args);
}
};
const logger = {
logMinimal: createLogger('Minimal'),
logTypical: createLogger('Typical'),
logDetailed: createLogger('Detailed'),
logAll: createLogger('All'),
logOverkill: createLogger('Overkill'),
};
this._registry.set(id, logger);
},
get(channelName) {
return this._registry.get(channelName.toLowerCase());
},
remove(channelName) {
const id = channelName.toLowerCase();
this._registry.delete(id);
this._subscribed.delete(id);
},
subscribe(channelName) {
const id = channelName.toLowerCase();
if (this._registry.has(id)) {
this._subscribed.add(id);
}
},
unsubscribe(channelName) {
this._subscribed.delete(channelName.toLowerCase());
},
subscribeAll() {
this._registry.forEach((_, key) => this._subscribed.add(key));
},
unsubscribeAll() {
this._subscribed.clear();
},
},
rebuild() {
const levelLoggers = {};
Object.entries(state.levels).forEach(([name, config]) => {
const isActive = state.enabled && state.level >= config.val;
const debugFunction =
isActive ?
console.log.bind(
window.console,
`%c[${state.badge}][${name}]`,
`color: ${config.color}; font-weight: bold;`,
)
: () => {};
debugApi[`log${name}`] = debugFunction;
levelLoggers[config.val] = debugFunction;
});
debugApi.log = (...args) => levelLoggers[1](...args);
Object.entries(levelLoggers).forEach(([level, func]) => {
debugApi.log[level] = func;
});
},
init(id) {
state.badge = `YT-Helper-API [${id}]`;
debugApi.rebuild();
},
};
return debugApi;
})();
// --- END DEBUG SYSTEM ---
// --- GM API SHIM ---
const gmCapabilities = { isModern: false, isLegacy: false, isMissing: true, features: {} };
(function performGmShim() {
const API_MAP = {
setValue: ['setValue', 'GM_setValue'],
getValue: ['getValue', 'GM_getValue'],
deleteValue: ['deleteValue', 'GM_deleteValue'],
listValues: ['listValues', 'GM_listValues'],
getResourceText: ['getResourceText', 'GM_getResourceText'],
getResourceURL: ['getResourceURL', 'GM_getResourceURL'],
addStyle: ['addStyle', 'GM_addStyle'],
addElement: ['addElement', 'GM_addElement'],
registerMenuCommand: ['registerMenuCommand', 'GM_registerMenuCommand'],
unregisterMenuCommand: ['unregisterMenuCommand', 'GM_unregisterMenuCommand'],
openInTab: ['openInTab', 'GM_openInTab'],
notification: ['notification', 'GM_notification'],
setClipboard: ['setClipboard', 'GM_setClipboard'],
contextMenu: ['contextMenu', 'GM_contextMenu'],
xmlhttpRequest: ['xmlHttpRequest', 'GM_xmlhttpRequest'],
download: ['download', 'GM_download'],
webRequest: ['webRequest', 'GM_webRequest'],
cookie: ['cookie', 'GM_cookie'],
saveTab: ['saveTab', 'GM_saveTab'],
getTab: ['getTab', 'GM_getTab'],
getTabs: ['getTabs', 'GM_getTabs'],
log: ['log', 'GM_log'],
info: ['info', 'GM_info'],
print: ['print', 'GM_print'],
};
const realGM = typeof GM !== 'undefined' ? GM : {};
gmCapabilities.isModern = typeof GM !== 'undefined';
gmCapabilities.isLegacy = typeof GM_info !== 'undefined';
gmCapabilities.isMissing = !gmCapabilities.isModern && !gmCapabilities.isLegacy;
Object.entries(API_MAP).forEach(([stdName, [modernProp, legacyGlobal]]) => {
const hasModern =
gmCapabilities.isModern && (Reflect.has(realGM, modernProp) || Reflect.has(realGM, stdName));
const hasLegacy = typeof window[legacyGlobal] !== 'undefined';
gmCapabilities.features[stdName] = hasModern || hasLegacy;
if (hasLegacy) gmCapabilities.isLegacy = true;
if (!hasLegacy) {
window[legacyGlobal] =
stdName === 'info' ? { script: { version: '0.0.0' }, scriptHandler: 'Shim' } : () => undefined;
}
});
try {
const proxyHandler = {
get(target, property) {
if (property === 'info') return target.info ?? { script: { version: '0.0.0' } };
let realProperty = property;
if (API_MAP[property]) realProperty = API_MAP[property][0];
if (Reflect.has(target, realProperty)) {
const value = target[realProperty];
return typeof value === 'function' ? value.bind(target) : value;
}
return () => {
const dummyPromise = Promise.resolve({ responseText: '', status: 200, statusText: 'OK' });
dummyPromise.abort = () => {
console.warn('[YouTube Helper API] Abort called on missing GM shim');
};
return dummyPromise;
};
},
};
try {
Object.defineProperty(window, 'GM', {
value: new Proxy(realGM, proxyHandler),
writable: true,
enumerable: true,
configurable: true,
});
} catch (definePropertyError) {
try {
delete window.GM;
window.GM = new Proxy(realGM, proxyHandler);
} catch (assignmentError) {
console.warn('[YouTube Helper API] Completely failed to patch window.GM', assignmentError);
}
}
} catch (error) {
console.warn('[YouTube Helper API] Critical shim error', error);
}
})();
// --- GM API SHIM END ---
const CONSTANTS = {
SELECTORS: Object.freeze({
pageManager: 'ytd-page-manager',
shortsPlayer: '#shorts-player',
watchPlayer: '#movie_player',
inlinePlayer: '.inline-preview-player',
videoElement: 'video',
watchFlexy: 'ytd-watch-flexy',
chatFrame: 'ytd-live-chat-frame#chat',
chatContainer: '#chat-container',
}),
EVENTS: Object.freeze({
API_READY: 'yt-helper-api-ready',
API_UPDATE_STARTED: 'yt-helper-api-update-started',
AD_DETECTED: 'yt-helper-api-ad-detected',
IFRAME_DETECTED: 'yt-helper-api-detected-iframe',
PLAYER_UPDATED: 'yt-player-updated',
VIDEO_LANGUAGE_UPDATED: 'yt-helper-api-playback-language-updated',
CHAT_STATE_UPDATED: 'yt-helper-api-chat-state-updated',
VIDEO_PLAY: 'yt-helper-api-current-video-play',
VIDEO_PAUSE: 'yt-helper-api-current-video-pause',
VIDEO_SEEKING: 'yt-helper-api-current-video-seeking',
VIDEO_SEEKED: 'yt-helper-api-current-video-seeked',
VIDEO_ENDED: 'yt-helper-api-current-video-ended',
VIDEO_VOLUMECHANGE: 'yt-helper-api-current-video-volumechange',
}),
POSSIBLE_RESOLUTIONS: Object.freeze({
highres: { p: 4320, label: '8K' },
hd2160: { p: 2160, label: '4K' },
hd1440: { p: 1440, label: '1440p' },
hd1080: { p: 1080, label: '1080p' },
hd720: { p: 720, label: '720p' },
large: { p: 480, label: '480p' },
medium: { p: 360, label: '360p' },
small: { p: 240, label: '240p' },
tiny: { p: 144, label: '144p' },
}),
};
const _readOnlyHandler = {
get(target, property) {
return target[property];
},
set(target, property) {
console.warn(`[YouTube Helper API] Tried to set "${property}" on a read-only object.`);
return true;
},
};
const StorageManager = {
logger: (() => {
debug.channels.add('storage');
return debug.channels.get('storage');
})(),
_storageActions: ['getValue', 'setValue', 'deleteValue', 'listValues'],
storageKey: '',
_activeReads: new Map(), // Promise cache for deduplicating simultaneous read requests
_updateQueues: new Map(), // Key-specific mutexes to prevent local race conditions during simultaneous updates
sessionStorageFallbackApi: {
get: (itemKey, defaultValue) => {
try {
const rootObj = JSON.parse(sessionStorage.getItem(StorageManager.storageKey) || '{}');
return rootObj[itemKey] !== undefined ? rootObj[itemKey] : defaultValue;
} catch (error) {
console.error(`Error loading sessionStorage for key "${itemKey}":`, error);
return defaultValue;
}
},
set: (itemKey, value) => {
try {
const rootObj = JSON.parse(sessionStorage.getItem(StorageManager.storageKey) || '{}');
rootObj[itemKey] = value;
sessionStorage.setItem(StorageManager.storageKey, JSON.stringify(rootObj));
} catch (error) {
console.error(`[YouTube Helper API] Error saving to sessionStorage for key "${itemKey}":`, error);
}
},
delete: (itemKey) => {
try {
const rootObj = JSON.parse(sessionStorage.getItem(StorageManager.storageKey) || '{}');
delete rootObj[itemKey];
sessionStorage.setItem(StorageManager.storageKey, JSON.stringify(rootObj));
} catch (error) {
console.error(
`[YouTube Helper API] Error deleting from sessionStorage for key "${itemKey}":`,
error,
);
}
},
list: () => {
try {
const rootObj = JSON.parse(sessionStorage.getItem(StorageManager.storageKey) || '{}');
return Object.keys(rootObj);
} catch (error) {
console.error('[YouTube Helper API] Error listing values in sessionStorage:', error);
return [];
}
},
},
localStorageFallbackApi: {
get: (itemKey, defaultValue) => {
try {
const rootObj = JSON.parse(localStorage.getItem(StorageManager.storageKey) || '{}');
return rootObj[itemKey] !== undefined ? rootObj[itemKey] : defaultValue;
} catch (error) {
console.error(`Error loading localStorage for key "${itemKey}":`, error);
return defaultValue;
}
},
set: (itemKey, value) => {
try {
const rootObj = JSON.parse(localStorage.getItem(StorageManager.storageKey) || '{}');
rootObj[itemKey] = value;
localStorage.setItem(StorageManager.storageKey, JSON.stringify(rootObj));
} catch (error) {
console.error(`[YouTube Helper API] Error saving to localStorage for key "${itemKey}":`, error);
}
},
delete: (itemKey) => {
try {
const rootObj = JSON.parse(localStorage.getItem(StorageManager.storageKey) || '{}');
delete rootObj[itemKey];
localStorage.setItem(StorageManager.storageKey, JSON.stringify(rootObj));
} catch (error) {
console.error(`[YouTube Helper API] Error deleting from localStorage for key "${itemKey}":`, error);
}
},
list: () => {
try {
const rootObj = JSON.parse(localStorage.getItem(StorageManager.storageKey) || '{}');
return Object.keys(rootObj);
} catch (error) {
console.error('[YouTube Helper API] Error listing values in localStorage:', error);
return [];
}
},
},
storageImplementations: {
modernGM: {
getValue: async (...args) => await GM.getValue(...args),
setValue: async (...args) => await GM.setValue(...args),
deleteValue: async (...args) => await GM.deleteValue(...args),
listValues: async (...args) => await GM.listValues(...args),
},
oldGM: {
getValue: async (key, defaultValue) => GM_getValue(key, defaultValue),
setValue: async (key, value) => GM_setValue(key, value),
deleteValue: async (key) => GM_deleteValue(key),
listValues: async () => GM_listValues(),
},
local: {
getValue: async (key, defaultValue) => StorageManager.localStorageFallbackApi.get(key, defaultValue),
setValue: async (key, value) => StorageManager.localStorageFallbackApi.set(key, value),
deleteValue: async (key) => StorageManager.localStorageFallbackApi.delete(key),
listValues: async () => StorageManager.localStorageFallbackApi.list(),
},
volatile: {
getValue: async (key, defaultValue) => StorageManager.sessionStorageFallbackApi.get(key, defaultValue),
setValue: async (key, value) => StorageManager.sessionStorageFallbackApi.set(key, value),
deleteValue: async (key) => StorageManager.sessionStorageFallbackApi.delete(key),
listValues: async () => StorageManager.sessionStorageFallbackApi.list(),
},
},
api: {
get validTypes() {
return Object.keys(StorageManager.storageImplementations);
},
get validActions() {
return StorageManager._storageActions;
},
storageImplementations(type) {
return new Proxy(StorageManager.storageImplementations[type], _readOnlyHandler);
},
},
_determineStorageSolution() {
const hasCustomStorageKey =
StorageManager.storageKey !== '' && StorageManager.storageKey !== InstanceManager.instance.id;
let storageTypesToUse = [];
if (gmCapabilities.isMissing) {
if (hasCustomStorageKey) {
storageTypesToUse.push('local');
} else {
StorageManager.storageKey = InstanceManager.instance.id;
console.warn(
`[YouTube Helper API] Storage key not set. Using instance ID: ${InstanceManager.instance.id} as fallback. Data will not persist across browser sessions.`,
);
storageTypesToUse.push('volatile');
}
} else {
storageTypesToUse.push(gmCapabilities.isModern ? 'modernGM' : 'oldGM');
if (hasCustomStorageKey) storageTypesToUse.push('local');
}
return storageTypesToUse;
},
async _getSyncedStorageData(itemKey, storageSolutionsToUse) {
const dataFetchPromises = storageSolutionsToUse.map(async (solution) => {
const savedData = await StorageManager.api.storageImplementations(solution).getValue(itemKey, null);
const dataTimestamp = savedData?.metadata?.timestamp ?? -1;
return { savedData, dataTimestamp };
});
const allSavedData = await Promise.all(dataFetchPromises);
allSavedData.sort((a, b) => b.dataTimestamp - a.dataTimestamp);
return allSavedData[0]?.savedData;
},
async _processStorageAction(itemKey, data, action) {
try {
if (!StorageManager.api.validActions.includes(action))
throw new Error(`Invalid storage action: ${action}`);
const storageSolutionsToUse = StorageManager._determineStorageSolution();
switch (action) {
case 'getValue': {
// Request Deduplication: Absorb simultaneous IPC calls across the same key
if (StorageManager._activeReads.has(itemKey)) {
return StorageManager._activeReads.get(itemKey);
}
const fetchPromise = (async () => {
try {
return await StorageManager._getSyncedStorageData(itemKey, storageSolutionsToUse);
} finally {
StorageManager._activeReads.delete(itemKey);
}
})();
StorageManager._activeReads.set(itemKey, fetchPromise);
return fetchPromise;
}
case 'setValue':
await Promise.all(
storageSolutionsToUse.map((solution) =>
StorageManager.api.storageImplementations(solution).setValue(itemKey, data),
),
);
return;
case 'deleteValue':
await Promise.all(
storageSolutionsToUse.map((solution) =>
StorageManager.api.storageImplementations(solution).deleteValue(itemKey),
),
);
return;
case 'listValues': {
const listKey = '__LIST_VALUES_DEDUPLICATION_KEY__';
if (StorageManager._activeReads.has(listKey)) {
return StorageManager._activeReads.get(listKey);
}
const listPromise = (async () => {
try {
const listPromises = storageSolutionsToUse.map((solution) =>
StorageManager.api.storageImplementations(solution).listValues(),
);
const results = await Promise.all(listPromises);
return results;
} finally {
StorageManager._activeReads.delete(listKey);
}
})();
StorageManager._activeReads.set(listKey, listPromise);
return listPromise;
}
}
} catch (error) {
console.error(`Error when processing storage action "${action}":`, error);
return action === 'listValues' ? [] : null;
}
},
_isPlainObject(item) {
return typeof item === 'object' && item !== null && !Array.isArray(item);
},
_deepMerge(target, source) {
if (!StorageManager._isPlainObject(target) || !StorageManager._isPlainObject(source)) {
return source !== undefined ? source : target;
}
const result = { ...target };
for (const key of Object.keys(source)) {
if (
StorageManager._isPlainObject(source[key]) &&
key in target &&
StorageManager._isPlainObject(target[key])
) {
result[key] = StorageManager._deepMerge(target[key], source[key]);
} else if (source[key] !== undefined) {
result[key] = source[key];
}
}
return result;
},
_deepClean(targetTemplate, data) {
if (!StorageManager._isPlainObject(targetTemplate)) return data;
const result = {};
for (const key of Object.keys(targetTemplate)) {
if (StorageManager._isPlainObject(targetTemplate[key]) && StorageManager._isPlainObject(data[key])) {
result[key] = StorageManager._deepClean(targetTemplate[key], data[key]);
} else {
result[key] = data[key];
}
}
return result;
},
async loadEntry(itemKey, defaultData) {
StorageManager.logger.logDetailed(`Loading from storage: ${itemKey}`);
try {
const syncedWrapper = await StorageManager._processStorageAction(itemKey, null, 'getValue');
if (syncedWrapper === null || syncedWrapper === undefined) return defaultData;
const storedData = !syncedWrapper.metadata ? syncedWrapper : syncedWrapper.data;
const isObject = StorageManager._isPlainObject(defaultData);
return isObject ?
StorageManager._deepMerge(defaultData, storedData ?? {})
: (storedData ?? defaultData);
} catch (error) {
console.error(`Error loading data for key "${itemKey}":`, error);
return defaultData;
}
},
async loadAndCleanEntry(itemKey, defaultData) {
StorageManager.logger.logDetailed(`Loading and cleaning storage: ${itemKey}`);
try {
const combinedData = await StorageManager.loadEntry(itemKey, defaultData);
if (!StorageManager._isPlainObject(defaultData)) {
return combinedData;
}
return StorageManager._deepClean(defaultData, combinedData);
} catch (error) {
console.error(`Error loading and cleaning data for key "${itemKey}":`, error);
return defaultData;
}
},
async saveEntry(itemKey, data) {
StorageManager.logger.logDetailed(`Saving to storage: ${itemKey}`);
const dataToStore = {
data: data,
metadata: { timestamp: Date.now() },
};
await StorageManager._processStorageAction(itemKey, dataToStore, 'setValue');
},
async deleteEntry(itemKey) {
StorageManager.logger.logDetailed(`Deleting from storage: ${itemKey}`);
await StorageManager._processStorageAction(itemKey, null, 'deleteValue');
},
async list() {
try {
const allKeyArrays = await StorageManager._processStorageAction(null, null, 'listValues');
const allUniqueKeys = new Set(allKeyArrays.flat().filter(Boolean));
return Array.from(allUniqueKeys);
} catch (error) {
console.error('Error listing storage values:', error);
return [];
}
},
async updateEntry(itemKey, mutatorCallback, defaultData = null, options = { strategy: 'optimistic' }) {
StorageManager.logger.logDetailed(`Updating storage entry: ${itemKey}`);
if (!StorageManager._updateQueues.has(itemKey)) StorageManager._updateQueues.set(itemKey, Promise.resolve());
const updateTask = StorageManager._updateQueues.get(itemKey).then(async () => {
const performUpdate = async () => {
const storageSolutionsToUse = StorageManager._determineStorageSolution();
const syncedWrapper = await StorageManager._getSyncedStorageData(itemKey, storageSolutionsToUse);
const storedData = !syncedWrapper || !syncedWrapper.metadata ? syncedWrapper : syncedWrapper.data;
const isObject = StorageManager._isPlainObject(defaultData);
const combinedData = isObject ? StorageManager._deepMerge(defaultData, storedData ?? {}) : (storedData ?? defaultData);
const newData = await mutatorCallback(combinedData);
const updateToken = typeof performance !== 'undefined' ? performance.now() % 1 : Math.random();
if (options.strategy === 'overwrite') {
const dataToStore = { data: newData, metadata: { timestamp: Date.now() + updateToken } };
await Promise.all(
storageSolutionsToUse.map((solution) =>
StorageManager.api.storageImplementations(solution).setValue(itemKey, dataToStore),
),
);
return;
}
const dataToStore = { data: newData, metadata: { timestamp: Date.now() + updateToken } };
await Promise.all(
storageSolutionsToUse.map((solution) =>
StorageManager.api.storageImplementations(solution).setValue(itemKey, dataToStore),
),
);
};
if (typeof navigator !== 'undefined' && navigator.locks) {
await navigator.locks.request(`yt-helper-storage-${itemKey}`, performUpdate);
} else {
let retries = 5;
while (retries > 0) {
try {
const storageSolutionsToUse = StorageManager._determineStorageSolution();
const syncedWrapper = await StorageManager._getSyncedStorageData(itemKey, storageSolutionsToUse);
const startTimestamp = syncedWrapper?.metadata?.timestamp ?? -1;
const storedData = !syncedWrapper || !syncedWrapper.metadata ? syncedWrapper : syncedWrapper.data;
const isObject = StorageManager._isPlainObject(defaultData);
const combinedData = isObject ? StorageManager._deepMerge(defaultData, storedData ?? {}) : (storedData ?? defaultData);
const newData = await mutatorCallback(combinedData);
const updateToken = typeof performance !== 'undefined' ? performance.now() % 1 : Math.random();
if (options.strategy === 'overwrite') {
const dataToStore = { data: newData, metadata: { timestamp: Date.now() + updateToken } };
await Promise.all(
storageSolutionsToUse.map((solution) =>
StorageManager.api.storageImplementations(solution).setValue(itemKey, dataToStore),
),
);
return;
}
const checkWrapper = await StorageManager._getSyncedStorageData(itemKey, storageSolutionsToUse);
const currentTimestamp = checkWrapper?.metadata?.timestamp ?? -1;
if (currentTimestamp === startTimestamp) {
const dataToStore = { data: newData, metadata: { timestamp: Date.now() + updateToken } };
await Promise.all(
storageSolutionsToUse.map((solution) =>
StorageManager.api.storageImplementations(solution).setValue(itemKey, dataToStore),
),
);
return;
} else {
StorageManager.logger.logTypical(`Timestamp mismatch for ${itemKey}, retrying...`);
}
} catch (e) {
StorageManager.logger.logTypical(`Error during optimistic update for ${itemKey}, retrying...`);
}
retries--;
}
throw new Error(`Failed to safely update storage for key: ${itemKey} after multiple retries.`);
}
});
const finalTask = updateTask.catch(() => {});
const cleanup = () => {
if (StorageManager._updateQueues.get(itemKey) === finalTask) {
StorageManager._updateQueues.delete(itemKey);
}
};
finalTask.finally(cleanup);
StorageManager._updateQueues.set(itemKey, finalTask);
return updateTask;
},
};
// A generic standarized extensible things for settings WIP. Must allow for overriding.
const ScriptSettingsManager = {
themes: {
youtubeLight: {},
youtubeDark: {},
},
defaultSettings: {
desktop: {
type: 'popup',
width: '640px',
height: '360px',
},
mobile: {
type: 'card',
},
},
config: {},
initializeUI() {
/* TODO */
},
// TODO: add more features in the future
};
// A generic standarized extensible things for YouTube layout management (including various DOM injection and manipulation) WIP
const YouTubeLayoutManager = {
injectUiElementToQuickActions(element, index = -1) {
// TODO: implement in the future
},
extractAndRefineNativeUiElement(rawElement) {
// TODO: implement in the future
const refinedElement = rawElement;
return refinedElement;
},
mirrorNativeUiElementAsGhost(element) {
// TODO: implement in the future
const ghostElement = element;
return ghostElement;
},
// TODO: add more features in the future
};
const YouTubeDataManager = {
appState: {
player: {
playerObject: null,
response: null,
api: null,
videoElement: null,
isFullscreen: false,
isTheater: false,
isPlayingAds: false,
},
video: {
id: '',
title: '',
channel: '',
channelId: '',
rawDescription: '',
rawUploadDate: '',
rawPublishDate: '',
uploadDate: null,
publishDate: null,
lengthSeconds: 0,
viewCount: 0,
likeCount: 0,
isCurrentlyLive: false,
isLiveOrVodContent: false,
isFamilySafe: false,
thumbnails: [],
playingLanguage: null,
originalLanguage: null,
isAutoDubbed: false,
realCurrentProgress: 0,
isTimeSpecified: false,
isInPlaylist: false,
playlistId: '',
},
chat: {
container: null,
iFrame: null,
isCollapsed: false,
},
page: (() => {
const _fallbackGetPageType = () => {
const pathname = window.location.pathname;
if (pathname.startsWith('/shorts')) return 'shorts';
if (pathname.startsWith('/watch')) return 'watch';
if (pathname.startsWith('/playlist')) return 'playlist';
if (pathname.startsWith('/results')) return 'search';
if (pathname === '/') return 'home';
return 'unknown';
};
let _type = 'unknown';
return {
get manager() {
return document.querySelector(CONSTANTS.SELECTORS.pageManager);
},
get watchFlexy() {
return document.querySelector(CONSTANTS.SELECTORS.watchFlexy);
},
isIframe: window.top !== window.self,
isMobile: window.location.hostname === 'm.youtube.com',
set type(newValue) {
_type = newValue;
},
get type() {
if (_type === 'unknown' || _type == null) return _fallbackGetPageType();
return _type;
},
};
})(),
},
nativePlayerProxy: (() => {
const methodCache = new Map();
return new Proxy(
{},
{
get(target, property) {
const api = YouTubeDataManager.appState.player.api;
if (!api) return undefined;
const value = api[property];
if (typeof value === 'function') {
if (!methodCache.has(property)) {
methodCache.set(property, (...args) => {
try {
return YouTubeDataManager.appState.player.api[property].apply(
YouTubeDataManager.appState.player.api,
args,
);
} catch (e) {
console.error(`API Call Error [${String(property)}]:`, e);
}
});
}
return methodCache.get(property);
}
return value;
},
},
);
})(),
getApiSnapshot() {
const eventDetail = { ...ApiManager.publicApi };
const stateSnapshot = {
player: { ...YouTubeDataManager.appState.player, videoElement: null, playerObject: null },
video: { ...YouTubeDataManager.appState.video },
chat: { ...YouTubeDataManager.appState.chat, container: null, iFrame: null },
page: { ...YouTubeDataManager.appState.page },
};
return Object.assign(eventDetail, stateSnapshot);
},
addPageStateListeners() {
document.addEventListener('yt-page-data-updated', YouTubeDataManager.handlePageDataUpdate);
document.addEventListener('yt-page-type-changed', YouTubeDataManager.handlePageTypeChange);
},
addPlayerStateListeners() {
const PLAYER_UPDATE_EVENT =
YouTubeDataManager.appState.page.isMobile ? 'video-data-change' : 'yt-player-updated';
document.addEventListener(PLAYER_UPDATE_EVENT, async (e) => await YouTubeDataManager.handlePlayerUpdate(e));
document.addEventListener('fullscreenchange', () => YouTubeDataManager.updateFullscreenState());
document.addEventListener('yt-set-theater-mode-enabled', (e) => YouTubeDataManager.updateTheaterState(e));
EventManager.eventTarget.addEventListener(CONSTANTS.EVENTS.VIDEO_PLAY, () => {
if (
YouTubeDataManager.nativePlayerProxy.getVideoData()?.video_id !==
YouTubeDataManager.appState.video.id
) {
debug.logDetailed('Video data updated without player event. Updating video state manually...');
YouTubeDataManager.appState.player.response =
YouTubeDataManager.nativePlayerProxy.getPlayerResponse();
YouTubeDataManager.updateVideoState();
const snapshot = YouTubeDataManager.getApiSnapshot();
EventManager.emit(CONSTANTS.EVENTS.API_READY, snapshot);
}
});
},
addChatStateListeners() {
document.addEventListener('yt-chat-collapsed-changed', (e) => YouTubeDataManager.updateChatStateUpdated(e));
},
addAllStateListeners() {
YouTubeDataManager.addPageStateListeners();
YouTubeDataManager.addPlayerStateListeners();
YouTubeDataManager.addChatStateListeners();
},
handlePageDataUpdate(event) {
YouTubeDataManager.appState.page.type = event.detail?.pageType;
debug.logDetailed('Page data updated', YouTubeDataManager.appState.page);
},
handlePageTypeChange(event) {
YouTubeDataManager.appState.page.type = event.detail?.newPageSubtype;
debug.logDetailed('Page type changed', YouTubeDataManager.appState.page);
},
handlePageshowEvent(event = null) {
debug.logDetailed('Pageshow event triggered');
const shouldTryEarly =
window.location.pathname.startsWith('/watch') ||
window.location.pathname.startsWith('/embed') ||
window.location.pathname.startsWith('/shorts');
if (shouldTryEarly) {
debug.logDetailed('Trying early player update...');
YouTubeDataManager.handlePlayerUpdate(event);
}
},
async fallbackGetPlayerApi(eventTarget = null) {
debug.logAll('Fallback Player API Check');
if (eventTarget?.getPlayer) return await eventTarget?.getPlayer();
debug.logDetailed('Invalid event for player api fallback. Trying with selectors...');
if (YouTubeDataManager.appState.page.isIframe || YouTubeDataManager.appState.page.isMobile)
return document.querySelector(CONSTANTS.SELECTORS.watchPlayer);
if (window.location.pathname.startsWith('/shorts'))
return document.querySelector(CONSTANTS.SELECTORS.shortsPlayer);
if (window.location.pathname.startsWith('/watch'))
return document.querySelector(CONSTANTS.SELECTORS.watchPlayer);
return document.querySelector(CONSTANTS.SELECTORS.inlinePlayer);
},
async getPlayerResponseWhenReady() {
if (!YouTubeDataManager.appState.player?.api) return null;
const fetchResponse = () => {
try {
return YouTubeDataManager.nativePlayerProxy.getPlayerResponse();
} catch (error) {
debug.logTypical('Error fetching playerResponse:', error);
return null;
}
}
let response = fetchResponse();
if (response) return response;
const isIframe = YouTubeDataManager.appState.page?.isIframe;
const videoElement = YouTubeDataManager.appState.player?.videoElement;
const timeoutLimit = 5000;
if (isIframe && videoElement) {
debug.logTypical('Iframe detected. Waiting for metadata to load...');
await new Promise((resolve) => {
const timeoutMetadataWait = setTimeout(() => {
debug.logDetailed('Iframe metadata synchronization timed out.');
resolve();
}, timeoutLimit);
const onMetadata = () => {
clearTimeout(timeoutMetadataWait);
resolve();
};
videoElement.addEventListener('loadedmetadata', onMetadata, { once: true });
});
}
for (let i = 0; i < 10; i++) {
response = fetchResponse();
if (response) return response;
await new Promise(resolve => setTimeout(resolve, 250));
}
debug.logDetailed('Player API ready, but missing playerResponse. Request timed out.');
return null;
},
async updatePlayerState(event) {
let actualTargetPlayer = event?.target;
if (event?.target !== document && event?.target?.getInlinePreviewPlayer) {
debug.logDetailed('Found valid event for player api fallback:');
actualTargetPlayer = await event.target.getInlinePreviewPlayer();
}
YouTubeDataManager.appState.player.api =
actualTargetPlayer?.player_ ?? (await YouTubeDataManager.fallbackGetPlayerApi(actualTargetPlayer));
const PlayerObjectCandidate =
actualTargetPlayer?.playerContainer_?.children[0] ??
await YouTubeDataManager.fallbackGetPlayerApi(actualTargetPlayer);
YouTubeDataManager.appState.player.playerObject =
PlayerObjectCandidate instanceof Element ? PlayerObjectCandidate : null;
YouTubeDataManager.appState.player.videoElement =
YouTubeDataManager.appState.player.playerObject?.querySelector(CONSTANTS.SELECTORS.videoElement);
YouTubeDataManager.appState.player.response = await YouTubeDataManager.getPlayerResponseWhenReady();
debug.logDetailed('Player state updated', YouTubeDataManager.appState.player);
},
updateVideoLanguage() {
if (!YouTubeDataManager.appState.player.api) return;
const availableTracks = YouTubeDataManager.nativePlayerProxy.getAvailableAudioTracks() ?? [];
const playingAudioTrack = YouTubeDataManager.nativePlayerProxy.getAudioTrack();
const getTrackDetails = (track) => Object.values(track ?? {});
const originalAudioTrack = availableTracks.find((track) => {
if (!track || typeof track !== 'object') return false;
const values = getTrackDetails(track);
const hasMetadata = values.some((val) => val && typeof val === 'object' && 'isAutoDubbed' in val);
const hasTrueFlag = values.some((val) => val === true);
return hasMetadata && hasTrueFlag;
});
const isAutoDubbed = getTrackDetails(playingAudioTrack).some((val) => val?.isAutoDubbed === true);
if (YouTubeDataManager.appState.video.playingLanguage === playingAudioTrack) return;
const isInit =
(YouTubeDataManager.appState.video.playingLanguage === null && `${playingAudioTrack}` !== 'Default') ||
`${YouTubeDataManager.appState.video.playingLanguage}` === 'Default';
debug.logTypical(`Language updated: ${playingAudioTrack} (Auto-Dubbed: ${isAutoDubbed})`);
YouTubeDataManager.appState.video.playingLanguage = playingAudioTrack;
YouTubeDataManager.appState.video.originalLanguage = originalAudioTrack;
YouTubeDataManager.appState.video.isAutoDubbed = isAutoDubbed;
EventManager.emit(
CONSTANTS.EVENTS.VIDEO_LANGUAGE_UPDATED,
Object.freeze({
isInit,
playingLanguage: YouTubeDataManager.appState.video.playingLanguage,
originalLanguage: YouTubeDataManager.appState.video.originalLanguage,
isAutoDubbed: YouTubeDataManager.appState.video.isAutoDubbed,
}),
);
},
updateVideoState() {
if (!YouTubeDataManager.appState.player.api)
return debug.logDetailed('No API found when attempting to update video state.');
const playerResponseObject = YouTubeDataManager.appState.player.response;
const searchParams = new URL(window.location.href).searchParams;
YouTubeDataManager.appState.video.id = playerResponseObject?.videoDetails?.videoId;
YouTubeDataManager.appState.video.title = playerResponseObject?.videoDetails?.title;
YouTubeDataManager.appState.video.channel = playerResponseObject?.videoDetails?.author;
YouTubeDataManager.appState.video.channelId = playerResponseObject?.videoDetails?.channelId;
YouTubeDataManager.appState.video.rawDescription = playerResponseObject?.videoDetails?.shortDescription;
YouTubeDataManager.appState.video.rawUploadDate =
playerResponseObject?.microformat?.playerMicroformatRenderer?.uploadDate;
YouTubeDataManager.appState.video.rawPublishDate =
playerResponseObject?.microformat?.playerMicroformatRenderer?.publishDate;
YouTubeDataManager.appState.video.uploadDate =
YouTubeDataManager.appState.video.rawUploadDate ?
new Date(YouTubeDataManager.appState.video.rawUploadDate)
: null;
YouTubeDataManager.appState.video.publishDate =
YouTubeDataManager.appState.video.rawPublishDate ?
new Date(YouTubeDataManager.appState.video.rawPublishDate)
: null;
YouTubeDataManager.appState.video.lengthSeconds = parseInt(
playerResponseObject?.videoDetails?.lengthSeconds ?? '0',
10,
);
YouTubeDataManager.appState.video.viewCount = parseInt(
playerResponseObject?.videoDetails?.viewCount ?? '0',
10,
);
YouTubeDataManager.appState.video.likeCount = parseInt(
playerResponseObject?.microformat?.playerMicroformatRenderer?.likeCount ?? '0',
10,
);
YouTubeDataManager.appState.video.isCurrentlyLive =
YouTubeDataManager.nativePlayerProxy.getVideoData()?.isLive;
YouTubeDataManager.appState.video.isLiveOrVodContent = playerResponseObject?.videoDetails?.isLiveContent;
YouTubeDataManager.appState.video.wasStreamedOrPremiered =
!!playerResponseObject?.microformat?.playerMicroformatRenderer?.liveBroadcastDetails;
YouTubeDataManager.appState.video.isFamilySafe =
playerResponseObject?.microformat?.playerMicroformatRenderer?.isFamilySafe;
YouTubeDataManager.appState.video.thumbnails =
playerResponseObject?.microformat?.playerMicroformatRenderer?.thumbnail?.thumbnails ??
playerResponseObject?.videoDetails?.thumbnail?.thumbnails;
YouTubeDataManager.appState.video.realCurrentProgress =
YouTubeDataManager.nativePlayerProxy.getCurrentTime();
YouTubeDataManager.appState.video.isTimeSpecified = searchParams.has('t');
YouTubeDataManager.appState.video.playlistId = YouTubeDataManager.nativePlayerProxy.getPlaylistId();
YouTubeDataManager.appState.video.isInPlaylist = !!YouTubeDataManager.appState.video.playlistId;
debug.logDetailed('Video state updated', YouTubeDataManager.appState.video);
},
updateFullscreenState() {
YouTubeDataManager.appState.player.isFullscreen = !!document.fullscreenElement;
debug.logDetailed(`Fullscreen: ${YouTubeDataManager.appState.player.isFullscreen}`);
},
updateTheaterState(event) {
YouTubeDataManager.appState.player.isTheater = !!event?.detail?.enabled;
debug.logDetailed(`Theater Mode: ${YouTubeDataManager.appState.player.isTheater}`);
},
updateChatStateUpdated(event) {
YouTubeDataManager.appState.chat.iFrame =
event?.target ?? document.querySelector(CONSTANTS.SELECTORS.chatFrame);
YouTubeDataManager.appState.chat.container =
YouTubeDataManager.appState.chat.iFrame?.parentElement ??
document.querySelector(CONSTANTS.SELECTORS.chatContainer);
YouTubeDataManager.appState.chat.isCollapsed = event?.detail ?? true;
debug.logDetailed('Chat state updated', YouTubeDataManager.appState.chat);
EventManager.emit(
CONSTANTS.EVENTS.CHAT_STATE_UPDATED,
Object.freeze({ ...YouTubeDataManager.appState.chat, iFrame: null, container: null }),
);
},
processAdState(isPlayingAds) {
YouTubeDataManager.appState.player.isPlayingAds = isPlayingAds;
if (isPlayingAds) {
debug.logTypical('Ad detected!');
EventManager.emit(CONSTANTS.EVENTS.AD_DETECTED, { isPlayingAds: true });
}
},
updateAdState() {
if (!YouTubeDataManager.appState.player.playerObject) return;
try {
const shouldAvoid =
YouTubeDataManager.appState.player.playerObject.classList.contains('unstarted-mode');
const isAdPresent =
YouTubeDataManager.appState.player.playerObject.classList.contains('ad-showing') ||
YouTubeDataManager.appState.player.playerObject.classList.contains('ad-interrupting');
const isPlayingAds = !shouldAvoid && isAdPresent;
YouTubeDataManager.processAdState(isPlayingAds);
} catch (error) {
console.error('Error in checkAdState:', error);
return false;
}
},
fallbackUpdateAdState() {
if (!YouTubeDataManager.appState.player.api) return;
try {
debug.logAll('Fallback Ad State Check');
const progressState = YouTubeDataManager.nativePlayerProxy.getProgressState();
const reportedContentDuration = progressState.duration;
const realContentDuration = YouTubeDataManager.nativePlayerProxy.getDuration() ?? -1;
const durationMismatch = Math.trunc(realContentDuration) !== Math.trunc(reportedContentDuration);
const isPlayingAds = durationMismatch;
YouTubeDataManager.processAdState(isPlayingAds);
} catch (error) {
console.error('Error during ad check:', error);
}
},
currentObservedAdContainer: null,
activeAdObserver: null,
trackAdState() {
const playerObject = YouTubeDataManager.appState.player.playerObject;
if (!playerObject || YouTubeDataManager.currentObservedAdContainer === playerObject) return;
if (YouTubeDataManager.activeAdObserver) YouTubeDataManager.activeAdObserver.disconnect();
YouTubeDataManager.activeAdObserver = new MutationObserver(YouTubeDataManager.updateAdState);
YouTubeDataManager.activeAdObserver.observe(playerObject, {
attributes: true,
attributeFilter: ['class'],
});
YouTubeDataManager.currentObservedAdContainer = playerObject;
},
videoEventController: null,
currentTrackedVideoElement: null,
trackPlaybackProgress() {
const videoElement = YouTubeDataManager.appState.player.videoElement;
if (!videoElement || YouTubeDataManager.currentTrackedVideoElement === videoElement) return;
if (YouTubeDataManager.videoEventController) YouTubeDataManager.videoEventController.abort();
YouTubeDataManager.videoEventController = new AbortController();
const signal = YouTubeDataManager.videoEventController.signal;
const updateProgress = () => {
debug.logOverkill(`TimeUpdate: ${YouTubeDataManager.appState.player.videoElement.currentTime}`);
if (
!YouTubeDataManager.appState.player.isPlayingAds &&
YouTubeDataManager.appState.player.videoElement.currentTime > 0
) {
YouTubeDataManager.appState.video.realCurrentProgress =
YouTubeDataManager.appState.player.videoElement.currentTime;
}
YouTubeDataManager.updateVideoLanguage();
};
YouTubeDataManager.setupMediaEventRefire(signal);
YouTubeDataManager.appState.player.videoElement.addEventListener('timeupdate', updateProgress, { signal });
YouTubeDataManager.currentTrackedVideoElement = videoElement;
},
updatePending: false,
updateLocked: false,
latestEvent: null,
async handlePlayerUpdate(event = null) {
YouTubeDataManager.latestEvent = event || YouTubeDataManager.latestEvent;
if (YouTubeDataManager.updatePending || YouTubeDataManager.updateLocked) return;
debug.logAll('Player update triggered. Unlocking...');
YouTubeDataManager.updatePending = true;
queueMicrotask(() => YouTubeDataManager._processPlayerUpdate());
debug.logDetailed('Player update triggered by:', event?.type || 'manual call');
},
async _processPlayerUpdate() {
YouTubeDataManager.updatePending = false;
YouTubeDataManager.updateLocked = true;
try {
EventManager.emit(CONSTANTS.EVENTS.API_UPDATE_STARTED);
const eventToProcess = YouTubeDataManager.latestEvent;
YouTubeDataManager.latestEvent = null;
await YouTubeDataManager.updatePlayerState(eventToProcess);
YouTubeDataManager.updateAdState();
YouTubeDataManager.trackAdState();
YouTubeDataManager.updateVideoState();
YouTubeDataManager.updateVideoLanguage();
YouTubeDataManager.trackPlaybackProgress();
const snapshot = YouTubeDataManager.getApiSnapshot();
if (snapshot.video.id) {
debug.logMinimal(`Video Ready. Title: "${snapshot.video.title}"...`);
queueMicrotask(() => EventManager.emit(CONSTANTS.EVENTS.API_READY, snapshot));
} else {
console.warn('Video ID not found in state snapshot.');
}
} catch (error) {
console.error('Error in _handlePlayerUpdate:', error);
} finally {
debug.logAll('Player update complete. Unlocking...');
YouTubeDataManager.updateLocked = false;
if (YouTubeDataManager.latestEvent) {
debug.logAll('New event arrived during previous lock. Re-processing...');
YouTubeDataManager.handlePlayerUpdate(); // Trigger the queue again if new event arrived while processing previous update.
} else {
debug.logAll('Player update complete. Unlocked.');
}
}
},
setupMediaEventRefire(signal) {
const video = YouTubeDataManager.appState.player.videoElement;
const nativePlayerEventsToRefire = {
play: CONSTANTS.EVENTS.VIDEO_PLAY,
pause: CONSTANTS.EVENTS.VIDEO_PAUSE,
seeking: CONSTANTS.EVENTS.VIDEO_SEEKING,
seeked: CONSTANTS.EVENTS.VIDEO_SEEKED,
ended: CONSTANTS.EVENTS.VIDEO_ENDED,
volumechange: CONSTANTS.EVENTS.VIDEO_VOLUMECHANGE,
};
Object.entries(nativePlayerEventsToRefire).forEach(([event, eventName]) => {
video.addEventListener(
event,
(e) => {
const customEvent = new CustomEvent(eventName, {
detail: {
originalEventType: e.type,
targetRef: e.target ? new WeakRef(e.target) : null, // weakRef allows for references to the event target node while still allowing cleanup.
currentTime: video.currentTime,
timestamp: Date.now(),
},
bubbles: true,
cancelable: true,
});
debug.logAll('refiring event', customEvent);
EventManager.eventTarget.dispatchEvent(customEvent);
},
{ signal },
);
});
},
};
const YouTubePlaybackUtilities = {
getOptimalResolution(targetResolutionString, usePremium = true) {
try {
if (!targetResolutionString || !CONSTANTS.POSSIBLE_RESOLUTIONS[targetResolutionString])
throw new Error(`Invalid target resolution: ${targetResolutionString}`);
const videoQualityData = YouTubeDataManager.nativePlayerProxy.getAvailableQualityData();
const availableQualities = [...new Set(videoQualityData.map((q) => q.quality))];
const targetValue = CONSTANTS.POSSIBLE_RESOLUTIONS[targetResolutionString].p;
const bestQualityString = availableQualities
.filter(
(q) => CONSTANTS.POSSIBLE_RESOLUTIONS[q] && CONSTANTS.POSSIBLE_RESOLUTIONS[q].p <= targetValue,
)
.sort((a, b) => CONSTANTS.POSSIBLE_RESOLUTIONS[b].p - CONSTANTS.POSSIBLE_RESOLUTIONS[a].p)[0];
if (!bestQualityString) return null;
let normalCandidate = null;
let premiumCandidate = null;
for (const quality of videoQualityData) {
if (quality.quality === bestQualityString && quality.isPlayable) {
if (usePremium && quality.paygatedQualityDetails) premiumCandidate = quality;
else normalCandidate = quality;
}
}
return premiumCandidate || normalCandidate;
} catch (error) {
console.error('Error when resolving optimal quality:', error);
return null;
}
},
setResolution(targetResolution, ignoreAvailable = false, usePremium = true) {
debug.logTypical(
`Attempting to set resolution: ${targetResolution} (Premium: ${usePremium}, Force: ${ignoreAvailable})`,
);
try {
if (!YouTubeDataManager.appState.player.api?.getAvailableQualityData) return;
if (!usePremium && ignoreAvailable) {
YouTubeDataManager.nativePlayerProxy.setPlaybackQualityRange(targetResolution);
} else {
const optimalQuality = YouTubePlaybackUtilities.getOptimalResolution(targetResolution, usePremium);
if (optimalQuality) {
debug.logDetailed('Found optimal quality format:', optimalQuality);
YouTubeDataManager.nativePlayerProxy.setPlaybackQualityRange(
optimalQuality.quality,
optimalQuality.quality,
usePremium ? optimalQuality.formatId : null,
);
} else {
debug.logTypical('Could not find a matching quality for:', targetResolution);
}
}
} catch (error) {
console.error('Error when setting resolution:', error);
}
},
reload(targetTime) {
if (!YouTubeDataManager.appState.player.api) return;
debug.logTypical(`Reloading video to ${targetTime}s`);
YouTubeDataManager.nativePlayerProxy.loadVideoById(YouTubeDataManager.appState.video.id, targetTime);
YouTubeDataManager.appState.player.videoElement =
YouTubeDataManager.appState.player.playerObject?.querySelector(CONSTANTS.SELECTORS.videoElement);
YouTubeDataManager.trackPlaybackProgress();
},
reloadToCurrentProgress() {
debug.logDetailed(`Reloading video to current progress`);
YouTubePlaybackUtilities.reload(YouTubeDataManager.appState.video.realCurrentProgress);
},
};
const EventManager = {
eventTarget: new EventTarget(),
emit(eventName, data = null) {
data ?
debug.logDetailed(`[Event] Dispatching: ${eventName}`, data)
: debug.logDetailed(`[Event] Dispatching Signal: ${eventName}`);
const event = new CustomEvent(eventName, {
detail: data ? Object.freeze(data) : null,
});
EventManager.eventTarget.dispatchEvent(event);
},
};
const InstanceManager = {
instance: {
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : null,
get shortId() {
return this.id ? this.id.split('-')[0] : 'anonymous';
},
root: typeof unsafeWindow !== 'undefined' ? unsafeWindow : (globalThis ?? window),
},
register(apiObject) {
const instance = InstanceManager.instance;
debug.log('Registering YouTube Helper API instance...', instance);
instance.root.youtubeHelperRegistry = instance.root.youtubeHelperRegistry ?? {
instances: new Map(),
list: () => {
console.table(
Array.from(instance.root.youtubeHelperRegistry.instances.values()).map((currentInstance) => ({
ID: currentInstance.instance.id,
Title: currentInstance.video.title,
Page: currentInstance.page.type,
})),
);
},
toggleAllDebug: () => {
instance.root.youtubeHelperRegistry.instances.forEach((currentInstance) => {
currentInstance.debug.enabled = !currentInstance.debug.enabled;
});
console.log(`[YouTube Helper API] Toggled debug mode for all active instances.`);
},
setAllDebug: (state) => {
instance.root.youtubeHelperRegistry.instances.forEach((currentInstance) => {
currentInstance.debug.enabled = state;
});
console.log(`[YouTube Helper API] Set debug mode for all active instances to "${state}".`);
},
setAllDebugLevel: (level) => {
instance.root.youtubeHelperRegistry.instances.forEach((currentInstance) => {
currentInstance.debug.level = level;
});
console.log(`[YouTube Helper API] Set debug level for all active instances to "${level}".`);
},
};
instance.root.youtubeHelperRegistry.instances.set(instance.id, apiObject);
window.addEventListener('unload', () => {
instance.root.youtubeHelperRegistry.instances.delete(instance.id);
});
},
};
const BroadcastManager = {
EVENT_PREFIX: 'broadcast:',
_subscribedChannels: new Map(),
get subscribedChannels() {
return Array.from(BroadcastManager._subscribedChannels.keys());
},
_openChannel(channelName) {
const channel = new BroadcastChannel(channelName.toString());
BroadcastManager._subscribedChannels.set(channelName, channel);
channel.onmessage = (event) => {
EventManager.emit(`${BroadcastManager.EVENT_PREFIX}${channelName}`, event.data);
};
},
_closeChannel(channelName) {
BroadcastManager._subscribedChannels.get(channelName).close();
BroadcastManager._subscribedChannels.delete(channelName);
},
subscribe(channelName) {
if (BroadcastManager._subscribedChannels.has(channelName)) return;
BroadcastManager._openChannel(channelName);
},
unsubscribe(channelName) {
if (BroadcastManager._subscribedChannels.has(channelName)) BroadcastManager._closeChannel(channelName);
},
notify(channelName, message) {
const payload = {
timestamp: Date.now(),
sourceInstanceId: InstanceManager.instance.id,
data: message,
};
if (BroadcastManager._subscribedChannels.has(channelName)) {
BroadcastManager._subscribedChannels.get(channelName).postMessage(payload);
} else {
const tempChannel = new BroadcastChannel(channelName.toString());
tempChannel.postMessage(payload);
tempChannel.close();
}
},
};
const ConcurrencyManager = {
logger: (() => {
debug.channels.add('concurrency');
return debug.channels.get('concurrency');
})(),
_getLockKey(lockName) {
return `_lock_${lockName}`;
},
async acquireLock(lockName, leaseDurationMs = 5000, options = { forceAcquire: false }) {
ConcurrencyManager.logger.logDetailed(`Attempting to acquire lock: ${lockName}`);
const lockKey = ConcurrencyManager._getLockKey(lockName);
const instanceId = InstanceManager.instance.id;
let acquired = false;
await StorageManager.updateEntry(
lockKey,
(currentLock) => {
acquired = false;
const now = Date.now();
// Check if lock is free, expired, or we force it, or we already own it
if (
options.forceAcquire ||
!currentLock ||
!currentLock.ownerId ||
currentLock.expiresAt < now ||
currentLock.ownerId === instanceId
) {
acquired = true;
return {
ownerId: instanceId,
expiresAt: now + leaseDurationMs,
leaseDurationMs: leaseDurationMs,
};
}
return currentLock; // Leave unchanged if we didn't acquire
},
null,
{ strategy: 'optimistic' },
);
return {
acquired,
lockName,
ownerId: instanceId,
};
},
async releaseLock(lockName) {
ConcurrencyManager.logger.logDetailed(`Releasing lock: ${lockName}`);
const lockKey = ConcurrencyManager._getLockKey(lockName);
const instanceId = InstanceManager.instance.id;
await StorageManager.updateEntry(
lockKey,
(currentLock) => {
if (currentLock && currentLock.ownerId === instanceId) {
return null; // Delete the lock if we own it
}
return currentLock;
},
null,
{ strategy: 'optimistic' },
);
},
async maintainLock(lockName) {
ConcurrencyManager.logger.logDetailed(`Maintaining lock: ${lockName}`);
const lockKey = ConcurrencyManager._getLockKey(lockName);
const instanceId = InstanceManager.instance.id;
await StorageManager.updateEntry(
lockKey,
(currentLock) => {
if (currentLock && currentLock.ownerId === instanceId) {
return {
...currentLock,
expiresAt: Date.now() + currentLock.leaseDurationMs,
};
}
return currentLock;
},
null,
{ strategy: 'optimistic' },
);
},
};
const ApiManager = {
readOnlyInstance: new Proxy(InstanceManager.instance, _readOnlyHandler),
readOnlyPlayer: new Proxy(YouTubeDataManager.appState.player, _readOnlyHandler),
readOnlyVideo: new Proxy(YouTubeDataManager.appState.video, _readOnlyHandler),
readOnlyChat: new Proxy(YouTubeDataManager.appState.chat, _readOnlyHandler),
readOnlyPage: new Proxy(YouTubeDataManager.appState.page, _readOnlyHandler),
publicApi: {
get instance() {
return ApiManager.readOnlyInstance;
},
get player() {
return ApiManager.readOnlyPlayer;
},
get video() {
return ApiManager.readOnlyVideo;
},
get chat() {
return ApiManager.readOnlyChat;
},
get page() {
return ApiManager.readOnlyPage;
},
playback: {
POSSIBLE_RESOLUTIONS: CONSTANTS.POSSIBLE_RESOLUTIONS,
getOptimalResolution: YouTubePlaybackUtilities.getOptimalResolution,
setResolution: YouTubePlaybackUtilities.setResolution,
reload: YouTubePlaybackUtilities.reload,
reloadToCurrentProgress: YouTubePlaybackUtilities.reloadToCurrentProgress,
},
storage: {
api: StorageManager.api,
get key() {
return StorageManager.storageKey;
},
set key(val) {
StorageManager.storageKey = val;
},
save: StorageManager.saveEntry,
load: StorageManager.loadEntry,
loadAndClean: StorageManager.loadAndCleanEntry,
update: StorageManager.updateEntry,
delete: StorageManager.deleteEntry,
list: StorageManager.list,
},
broadcast: {
get EVENT_PREFIX() {
return BroadcastManager.EVENT_PREFIX;
},
get subscribedChannels() {
return BroadcastManager.subscribedChannels;
},
subscribe: BroadcastManager.subscribe,
unsubscribe: BroadcastManager.unsubscribe,
notify: BroadcastManager.notify,
},
concurrency: {
acquireLock: ConcurrencyManager.acquireLock,
releaseLock: ConcurrencyManager.releaseLock,
maintainLock: ConcurrencyManager.maintainLock,
},
gmCapabilities,
apiProxy: YouTubeDataManager.nativePlayerProxy,
debug: {
get enabled() {
return debug.enabled;
},
set enabled(v) {
debug.enabled = v;
},
get level() {
return debug.level;
},
set level(v) {
debug.level = v;
},
channels: debug.channels,
get log() {
return debug.log;
},
get logMinimal() {
return debug.logMinimal;
},
get logTypical() {
return debug.logTypical;
},
get logDetailed() {
return debug.logDetailed;
},
get logAll() {
return debug.logAll;
},
get logOverkill() {
return debug.logOverkill;
},
},
EVENTS: CONSTANTS.EVENTS,
eventTarget: EventManager.eventTarget,
},
initializePrivateState() {
debug.log[0]('[YouTube Helper API] Library Initialized. Waiting for player...');
window.addEventListener('pageshow', (e) => YouTubeDataManager.handlePageshowEvent(e));
window.addEventListener('player-api-ready', (e) => YouTubeDataManager.handlePlayerUpdate(e));
if (YouTubeDataManager.appState.page.isIframe) EventManager.emit(CONSTANTS.EVENTS.IFRAME_DETECTED);
if (!YouTubeDataManager.appState.page.isIframe) YouTubeDataManager.addAllStateListeners();
},
initialize() {
try {
debug.init(InstanceManager.instance.shortId);
const initFlags = { supportsCryptography: true };
if (!crypto?.randomUUID) {
initFlags.supportsCryptography = false;
console.warn('[YouTube Helper API] Browser missing cryptography features.');
}
ApiManager.initializePrivateState();
if (initFlags.supportsCryptography) InstanceManager.register(ApiManager.publicApi);
return ApiManager.publicApi;
} catch (error) {
console.error('[YouTube Helper API] Error initializing:', error);
return null;
}
},
};
return ApiManager.initialize();
})();