Files
youtube-music/src/renderer.ts
JellyBrick d5a5ed35b6 fix: fix trustedHTML issue
- (Maybe) fix #2339, caused by YouTube's A/B testing
2024-09-17 20:05:22 +09:00

340 lines
10 KiB
TypeScript

import i18next from 'i18next';
import { startingPages } from './providers/extracted-data';
import setupSongInfo from './providers/song-info-front';
import {
createContext,
forceLoadRendererPlugin,
forceUnloadRendererPlugin,
getAllLoadedRendererPlugins,
getLoadedRendererPlugin,
loadAllRendererPlugins,
} from './loader/renderer';
import { loadI18n, setLanguage, t as i18t } from '@/i18n';
import type { PluginConfig } from '@/types/plugins';
import type { YoutubePlayer } from '@/types/youtube-player';
import type { QueueElement } from '@/types/queue';
import type { QueueResponse } from '@/types/youtube-music-desktop-internal';
let api: (Element & YoutubePlayer) | null = null;
let isPluginLoaded = false;
let isApiLoaded = false;
let firstDataLoaded = false;
if (window.trustedTypes && window.trustedTypes.createPolicy && !window.trustedTypes.defaultPolicy) {
window.trustedTypes.createPolicy('default', {
createHTML: (input) => input,
createScriptURL: (input) => input,
createScript: (input) => input,
});
}
async function listenForApiLoad() {
if (!isApiLoaded) {
api = document.querySelector('#movie_player');
if (api) {
await onApiLoaded();
return;
}
}
}
interface YouTubeMusicAppElement extends HTMLElement {
navigate(page: string): void;
}
async function onApiLoaded() {
window.ipcRenderer.on('ytmd:previous-video', () => {
document.querySelector<HTMLElement>('.previous-button.ytmusic-player-bar')?.click();
});
window.ipcRenderer.on('ytmd:next-video', () => {
document.querySelector<HTMLElement>('.next-button.ytmusic-player-bar')?.click();
});
window.ipcRenderer.on('ytmd:toggle-play', (_) => {
if (api?.getPlayerState() === 2) api?.playVideo();
else api?.pauseVideo();
});
window.ipcRenderer.on('ytmd:seek-to', (_, t: number) => api!.seekTo(t));
window.ipcRenderer.on('ytmd:seek-by', (_, t: number) => api!.seekBy(t));
window.ipcRenderer.on('ytmd:shuffle', () => {
document.querySelector<HTMLElement & { queue: { shuffle: () => void } }>('ytmusic-player-bar')?.queue.shuffle();
});
window.ipcRenderer.on('ytmd:update-like', (_, status: 'LIKE' | 'DISLIKE' = 'LIKE') => {
document.querySelector<HTMLElement & { updateLikeStatus: (status: string) => void }>('#like-button-renderer')?.updateLikeStatus(status);
});
window.ipcRenderer.on('ytmd:switch-repeat', (_, repeat = 1) => {
for (let i = 0; i < repeat; i++) {
document.querySelector<HTMLElement & { onRepeatButtonClick: () => void }>('ytmusic-player-bar')?.onRepeatButtonClick();
}
});
window.ipcRenderer.on('ytmd:update-volume', (_, volume: number) => {
document
.querySelector<
HTMLElement & { updateVolume: (volume: number) => void }
>('ytmusic-player-bar')
?.updateVolume(volume);
});
const isFullscreen = () => {
const isFullscreen =
document
.querySelector<HTMLElement>('ytmusic-player-bar')
?.attributes.getNamedItem('player-fullscreened') ?? null;
return isFullscreen !== null;
};
const clickFullscreenButton = (isFullscreenValue: boolean) => {
const fullscreen = isFullscreen();
if (isFullscreenValue === fullscreen) {
return;
}
if (fullscreen) {
document.querySelector<HTMLElement>('.exit-fullscreen-button')?.click();
} else {
document.querySelector<HTMLElement>('.fullscreen-button')?.click();
}
};
window.ipcRenderer.on('ytmd:get-fullscreen', (event) => {
event.sender.send('ytmd:set-fullscreen', isFullscreen());
});
window.ipcRenderer.on('ytmd:click-fullscreen-button', (_, fullscreen: boolean | undefined) => {
clickFullscreenButton(fullscreen ?? false);
});
window.ipcRenderer.on('ytmd:toggle-mute', (_) => {
document.querySelector<HTMLElement & { onVolumeTap: () => void }>('ytmusic-player-bar')?.onVolumeTap();
});
window.ipcRenderer.on('ytmd:get-queue', (event) => {
const queue = document.querySelector<QueueElement>('#queue');
event.sender.send('ytmd:get-queue-response', {
items: queue?.queue.getItems(),
autoPlaying: queue?.queue.autoPlaying,
continuation: queue?.queue.continuation,
} satisfies QueueResponse);
});
const video = document.querySelector('video')!;
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(video);
audioSource.connect(audioContext.destination);
for await (const [id, plugin] of Object.entries(
getAllLoadedRendererPlugins(),
)) {
if (typeof plugin.renderer !== 'function') {
await plugin.renderer?.onPlayerApiReady?.call(
plugin.renderer,
api!,
createContext(id),
);
}
}
if (firstDataLoaded) {
document.dispatchEvent(
new CustomEvent('videodatachange', { detail: { name: 'dataloaded' } }),
);
}
const audioCanPlayEventDispatcher = () => {
document.dispatchEvent(
new CustomEvent('ytmd:audio-can-play', {
detail: {
audioContext,
audioSource,
},
}),
);
};
const loadstartListener = () => {
// Emit "audioCanPlay" for each video
video.addEventListener('canplaythrough', audioCanPlayEventDispatcher, {
once: true,
});
};
if (video.readyState === 4 /* HAVE_ENOUGH_DATA (loaded) */) {
audioCanPlayEventDispatcher();
}
video.addEventListener('loadstart', loadstartListener, { passive: true });
window.ipcRenderer.send('ytmd:player-api-loaded');
// Navigate to "Starting page"
const startingPage: string = window.mainConfig.get('options.startingPage');
if (startingPage && startingPages[startingPage]) {
document
.querySelector<YouTubeMusicAppElement>('ytmusic-app')
?.navigate(startingPages[startingPage]);
}
// Remove upgrade button
if (window.mainConfig.get('options.removeUpgradeButton')) {
const itemsSelector = 'ytmusic-guide-section-renderer #items';
let selector = 'ytmusic-guide-entry-renderer:last-child';
const upgradeBtnIcon = document.querySelector<SVGGElement>('iron-iconset-svg[name="yt-sys-icons"] #youtube_music_monochrome');
if (upgradeBtnIcon) {
const path = upgradeBtnIcon.firstChild as SVGPathElement;
const data = path.getAttribute('d')!.substring(0, 15);
selector = `ytmusic-guide-entry-renderer:has(> tp-yt-paper-item > yt-icon path[d^="${data}"])`;
}
const styles = document.createElement('style');
styles.textContent = `${itemsSelector} ${selector} { display: none; }`;
document.head.appendChild(styles);
}
// Hide / Force show like buttons
const likeButtonsOptions: string = window.mainConfig.get(
'options.likeButtons',
);
if (likeButtonsOptions) {
const likeButtons: HTMLElement | null = document.querySelector(
'ytmusic-like-button-renderer',
);
if (likeButtons) {
likeButtons.style.display =
{
hide: 'none',
force: 'inherit',
}[likeButtonsOptions] || '';
}
}
}
/**
* YouTube Music still using ES5, so we need to define custom elements using ES5 style
*/
const defineYTMDTransElements = () => {
const YTMDTrans = function () {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
YTMDTrans.prototype = Object.create(HTMLElement.prototype);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
YTMDTrans.prototype.connectedCallback = function () {
const that = this as HTMLElement;
const key = that.getAttribute('key');
if (key) {
that.innerHTML = i18t(key);
}
};
customElements.define(
'ytmd-trans',
YTMDTrans as unknown as CustomElementConstructor,
);
};
const preload = async () => {
await loadI18n();
await setLanguage(window.mainConfig.get('options.language') ?? 'en');
window.i18n = {
t: i18t.bind(i18next),
};
defineYTMDTransElements();
if (document.body?.dataset?.os) {
document.body.dataset.os = navigator.userAgent;
}
};
const main = async () => {
await loadAllRendererPlugins();
isPluginLoaded = true;
window.ipcRenderer.on('plugin:unload', async (_event, id: string) => {
await forceUnloadRendererPlugin(id);
});
window.ipcRenderer.on('plugin:enable', async (_event, id: string) => {
await forceLoadRendererPlugin(id);
if (api) {
const plugin = getLoadedRendererPlugin(id);
if (plugin && typeof plugin.renderer !== 'function') {
await plugin.renderer?.onPlayerApiReady?.call(
plugin.renderer,
api,
createContext(id),
);
}
}
});
window.ipcRenderer.on(
'config-changed',
(_event, id: string, newConfig: PluginConfig) => {
const plugin = getAllLoadedRendererPlugins()[id];
if (plugin && typeof plugin.renderer !== 'function') {
plugin.renderer?.onConfigChange?.call(plugin.renderer, newConfig);
}
},
);
// Wait for complete load of YouTube api
await listenForApiLoad();
// Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min
setInterval(() => (window._lact = Date.now()), 900_000);
// Setup back to front logger
if (window.electronIs.dev()) {
window.ipcRenderer.on('log', (_event, log: string) => {
console.log(JSON.parse(log));
});
}
};
const initObserver = async () => {
// check document.documentElement is ready
await new Promise<void>((resolve) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => resolve(), {
once: true,
});
} else {
resolve();
}
});
const observer = new MutationObserver(() => {
const playerApi = document.querySelector<Element & YoutubePlayer>(
'#movie_player',
);
if (playerApi) {
observer.disconnect();
// Inject song-info provider
setupSongInfo(playerApi);
const dataLoadedListener = (name: string) => {
if (!firstDataLoaded && name === 'dataloaded') {
firstDataLoaded = true;
playerApi.removeEventListener('videodatachange', dataLoadedListener);
}
};
playerApi.addEventListener('videodatachange', dataLoadedListener);
if (isPluginLoaded && !isApiLoaded) {
api = playerApi;
isApiLoaded = true;
onApiLoaded();
}
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
};
initObserver().then(preload).then(main);