feat: migrate from raw HTML to JSX (TSX / SolidJS) (#3583)

Co-authored-by: Su-Yong <simssy2205@gmail.com>
This commit is contained in:
JellyBrick
2025-07-09 23:14:11 +09:00
committed by GitHub
parent 9ccd126eee
commit e114e0ef44
90 changed files with 1723 additions and 1357 deletions

View File

@ -3,7 +3,7 @@ import { createPlugin } from '@/utils';
import { onConfigChange, onMainLoad } from './main';
import { onMenu } from './menu';
import { onPlayerApiReady, onRendererLoad } from './renderer';
import { onPlayerApiReady } from './renderer';
import { t } from '@/i18n';
export type PictureInPicturePluginConfig = {
@ -41,7 +41,6 @@ export default createPlugin({
onConfigChange,
},
renderer: {
start: onRendererLoad,
onPlayerApiReady,
},
});

View File

@ -45,7 +45,7 @@ export const onMainLoad = async ({
window.setMaximizable(false);
window.setFullScreenable(false);
send('pip-toggle', true);
send('ytmd:pip-toggle', true);
app.dock?.hide();
window.setVisibleOnAllWorkspaces(true, {
@ -63,7 +63,7 @@ export const onMainLoad = async ({
window.setMaximizable(true);
window.setFullScreenable(true);
send('pip-toggle', false);
send('ytmd:pip-toggle', false);
window.setVisibleOnAllWorkspaces(false);
window.setAlwaysOnTop(false);

View File

@ -1,207 +0,0 @@
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
import keyEventAreEqual from 'keyboardevents-areequal';
import pipHTML from './templates/picture-in-picture.html?raw';
import { getSongMenu } from '@/providers/dom-elements';
import { ElementFromHtml } from '../utils/renderer';
import type { PictureInPicturePluginConfig } from './index';
import type { RendererContext } from '@/types/contexts';
function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector);
}
let useNativePiP = false;
let menu: Element | null = null;
const pipButton = ElementFromHtml(pipHTML);
let doneFirstLoad = false;
// Will also clone
function replaceButton(query: string, button: Element) {
const svg = button.querySelector('#icon svg')?.cloneNode(true);
if (svg) {
button.replaceWith(button.cloneNode(true));
button.remove();
const newButton = $(query);
if (newButton) {
newButton.querySelector('#icon')?.append(svg);
}
return newButton;
}
return null;
}
function cloneButton(query: string) {
const button = $(query);
if (button) {
replaceButton(query, button);
}
return $(query);
}
const observer = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) {
return;
}
}
if (
menu.contains(pipButton) ||
!(
menu.parentElement as (HTMLElement & { eventSink_: Element }) | null
)?.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar')
) {
return;
}
// check for video (or music)
let menuUrl = $<HTMLAnchorElement>(
'tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint',
)?.href;
if (!menuUrl?.includes('watch?')) {
menuUrl = undefined;
// check for podcast
for (const it of document.querySelectorAll(
'tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint',
)) {
if (it.getAttribute('href')?.includes('podcast/')) {
menuUrl = it.getAttribute('href')!;
break;
}
}
}
if (!menuUrl && doneFirstLoad) {
return;
}
menu.prepend(pipButton);
if (!doneFirstLoad) {
setTimeout(() => (doneFirstLoad ||= true), 500);
}
});
const togglePictureInPicture = async () => {
if (useNativePiP) {
const isInPiP = document.pictureInPictureElement !== null;
const video = $<HTMLVideoElement>('video');
const togglePiP = () =>
isInPiP
? document.exitPictureInPicture.call(document)
: video?.requestPictureInPicture?.call(video);
try {
await togglePiP();
$<HTMLButtonElement>('#icon')?.click(); // Close the menu
return true;
} catch {}
}
window.ipcRenderer.send('plugin:toggle-picture-in-picture');
return false;
};
// For UI (HTML)
window.togglePictureInPicture = togglePictureInPicture;
const listenForToggle = () => {
const originalExitButton = $<HTMLButtonElement>('.exit-fullscreen-button');
const appLayout = $<HTMLElement>('ytmusic-app-layout');
const expandMenu = $<HTMLElement>('#expanding-menu');
const middleControls = $<HTMLButtonElement>('.middle-controls');
const playerPage = $<HTMLElement & { playerPageOpen_: boolean }>(
'ytmusic-player-page',
);
const togglePlayerPageButton = $<HTMLButtonElement>(
'.toggle-player-page-button',
);
const fullScreenButton = $<HTMLButtonElement>('.fullscreen-button');
const player = $<
HTMLVideoElement & { onDoubleClick_: (() => void) | undefined }
>('#player');
const onPlayerDblClick = player?.onDoubleClick_;
const mouseLeaveEventListener = () => middleControls?.click();
const titlebar = $<HTMLElement>('.cet-titlebar');
window.ipcRenderer.on('pip-toggle', (_, isPip: boolean) => {
if (originalExitButton && player) {
if (isPip) {
replaceButton(
'.exit-fullscreen-button',
originalExitButton,
)?.addEventListener('click', () => togglePictureInPicture());
player.onDoubleClick_ = () => {};
expandMenu?.addEventListener('mouseleave', mouseLeaveEventListener);
if (!playerPage?.playerPageOpen_) {
togglePlayerPageButton?.click();
}
fullScreenButton?.click();
appLayout?.classList.add('pip');
if (titlebar) {
titlebar.style.display = 'none';
}
} else {
$('.exit-fullscreen-button')?.replaceWith(originalExitButton);
player.onDoubleClick_ = onPlayerDblClick;
expandMenu?.removeEventListener('mouseleave', mouseLeaveEventListener);
originalExitButton.click();
appLayout?.classList.remove('pip');
if (titlebar) {
titlebar.style.display = 'flex';
}
}
}
});
};
export const onRendererLoad = async ({
getConfig,
}: RendererContext<PictureInPicturePluginConfig>) => {
const config = await getConfig();
useNativePiP = config.useNativePiP;
if (config.hotkey) {
const hotkeyEvent = toKeyEvent(config.hotkey);
window.addEventListener('keydown', (event) => {
if (
keyEventAreEqual(event, hotkeyEvent) &&
!$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
) {
togglePictureInPicture();
}
});
}
};
export const onPlayerApiReady = () => {
listenForToggle();
cloneButton('.player-minimize-button')?.addEventListener(
'click',
async () => {
await togglePictureInPicture();
setTimeout(() => $<HTMLButtonElement>('#player')?.click());
},
);
// Allows easily closing the menu by programmatically clicking outside of it
$('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click');
// TODO: think about wether an additional button in songMenu is needed
const popupContainer = $('ytmusic-popup-container');
if (popupContainer)
observer.observe(popupContainer, {
childList: true,
subtree: true,
});
};

View File

@ -0,0 +1,166 @@
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
import keyEventAreEqual from 'keyboardevents-areequal';
import { render } from 'solid-js/web';
import { getSongMenu } from '@/providers/dom-elements';
import { isMusicOrVideoTrack } from '@/plugins/utils/renderer/check';
import { t } from '@/i18n';
import { PictureInPictureButton } from './templates/picture-in-picture-button';
import type { YoutubePlayer } from '@/types/youtube-player';
import type { PictureInPicturePluginConfig } from './index';
import type { RendererContext } from '@/types/contexts';
export const onPlayerApiReady = async (
_: YoutubePlayer,
{ ipc, getConfig }: RendererContext<PictureInPicturePluginConfig>,
) => {
const config = await getConfig();
const togglePictureInPicture = async () => {
if ((await getConfig()).useNativePiP) {
const isInPiP = document.pictureInPictureElement !== null;
const video = document.querySelector<HTMLVideoElement>('video');
const togglePiP = () =>
isInPiP
? document.exitPictureInPicture()
: video?.requestPictureInPicture();
try {
await togglePiP();
document.querySelector<HTMLButtonElement>('#icon')?.click(); // Close the menu
return true;
} catch {}
}
ipc.send('plugin:toggle-picture-in-picture');
return false;
};
if (config.hotkey) {
const hotkeyEvent = toKeyEvent(config.hotkey);
window.addEventListener('keydown', (event) => {
if (
keyEventAreEqual(event, hotkeyEvent) &&
!document.querySelector<HTMLElement & { opened: boolean }>(
'ytmusic-search-box',
)?.opened
) {
togglePictureInPicture();
}
});
}
const exitFullScreenButton = document.querySelector<HTMLButtonElement>(
'.exit-fullscreen-button',
);
const getPlayMinimizeButton = () =>
document.querySelector('.player-minimize-button');
const appLayout = document.querySelector<HTMLElement>('ytmusic-app-layout');
const expandMenu = document.querySelector<HTMLElement>('#expanding-menu');
const middleControls =
document.querySelector<HTMLButtonElement>('.middle-controls');
const playerPage = document.querySelector<
HTMLElement & { playerPageOpen_: boolean }
>('ytmusic-player-page');
const togglePlayerPageButton = document.querySelector<HTMLButtonElement>(
'.toggle-player-page-button',
);
const fullScreenButton =
document.querySelector<HTMLButtonElement>('.fullscreen-button');
const player = document.querySelector<
HTMLVideoElement & { onDoubleClick_: (() => void) | undefined }
>('#player');
const onPlayerDblClick = player?.onDoubleClick_;
const mouseLeaveEventListener = () => middleControls?.click();
const titleBar = document.querySelector<HTMLElement>(
'nav[data-ytmd-main-panel]',
);
const pipClickEventListener = async (e: Event) => {
e.stopPropagation();
e.preventDefault();
await togglePictureInPicture();
};
ipc.on('ytmd:pip-toggle', (isPip: boolean) => {
if (exitFullScreenButton && player) {
if (isPip) {
exitFullScreenButton?.addEventListener('click', pipClickEventListener);
getPlayMinimizeButton()?.removeEventListener(
'click',
pipClickEventListener,
);
player.onDoubleClick_ = () => {};
expandMenu?.addEventListener('mouseleave', mouseLeaveEventListener);
if (!playerPage?.playerPageOpen_) {
togglePlayerPageButton?.click();
}
fullScreenButton?.click();
appLayout?.classList.add('pip');
if (titleBar) {
titleBar.style.display = 'none';
}
} else {
exitFullScreenButton.removeEventListener(
'click',
pipClickEventListener,
);
getPlayMinimizeButton()?.addEventListener(
'click',
pipClickEventListener,
);
player.onDoubleClick_ = onPlayerDblClick;
expandMenu?.removeEventListener('mouseleave', mouseLeaveEventListener);
exitFullScreenButton.click();
appLayout?.classList.remove('pip');
if (titleBar) {
titleBar.style.display = 'flex';
}
}
}
});
getPlayMinimizeButton()?.addEventListener('click', pipClickEventListener);
const pipButtonContainer = document.createElement('div');
pipButtonContainer.classList.add(
'style-scope',
'menu-item',
'ytmusic-menu-popup-renderer',
);
pipButtonContainer.setAttribute('aria-disabled', 'false');
pipButtonContainer.setAttribute('aria-selected', 'false');
pipButtonContainer.setAttribute('role', 'option');
pipButtonContainer.setAttribute('tabindex', '-1');
render(
() => (
<PictureInPictureButton
onClick={togglePictureInPicture}
text={t('plugins.picture-in-picture.templates.button')}
/>
),
pipButtonContainer,
);
const observer = new MutationObserver(() => {
const menu = getSongMenu();
if (menu?.contains(pipButtonContainer) || !isMusicOrVideoTrack()) {
return;
}
menu?.prepend(pipButtonContainer);
});
observer.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true,
subtree: true,
});
};

View File

@ -0,0 +1,47 @@
export interface PictureInPictureButtonProps {
onClick?: (e: MouseEvent) => void;
text: string;
}
export const PictureInPictureButton = (props: PictureInPictureButtonProps) => (
<a
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex={-1}
onClick={props.onClick}
>
<div class="icon ytmd-menu-item style-scope ytmusic-menu-navigation-item-renderer">
<svg
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
id="Layer_1"
viewBox="0 0 512 512"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px"
>
<g class="style-scope yt-icon" id="XMLID_6_">
<path
class="style-scope yt-icon"
fill="#aaaaaa"
d="M418.5,139.4H232.4v139.8h186.1V139.4z M464.8,46.7H46.3C20.5,46.7,0,68.1,0,93.1v325.9
c0,25.8,21.4,46.3,46.3,46.3h419.4c25.8,0,46.3-20.5,46.3-46.3V93.1C512,67.2,490.6,46.7,464.8,46.7z M464.8,418.9H46.3V92.2h419.4
v326.8H464.8z"
id="XMLID_11_"
/>
</g>
</svg>
</div>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-pip"
>
{props.text}
</div>
</a>
);

View File

@ -1,52 +0,0 @@
<div
aria-disabled="false"
aria-selected="false"
class="style-scope menu-item ytmusic-menu-popup-renderer"
onclick="togglePictureInPicture()"
role="option"
tabindex="-1"
>
<div
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex="-1"
>
<div
class="icon ytmd-menu-item style-scope ytmusic-menu-navigation-item-renderer"
>
<svg
id="Layer_1"
style="enable-background: new 0 0 512 512"
version="1.1"
viewBox="0 0 512 512"
x="0px"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
y="0px"
>
<style type="text/css">
.st0 {
fill: #aaaaaa;
}
</style>
<g id="XMLID_6_">
<path
class="st0"
d="M418.5,139.4H232.4v139.8h186.1V139.4z M464.8,46.7H46.3C20.5,46.7,0,68.1,0,93.1v325.9
c0,25.8,21.4,46.3,46.3,46.3h419.4c25.8,0,46.3-20.5,46.3-46.3V93.1C512,67.2,490.6,46.7,464.8,46.7z M464.8,418.9H46.3V92.2h419.4
v326.8H464.8z"
id="XMLID_11_"
/>
</g>
</svg>
</div>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-pip"
>
<ytmd-trans
key="plugins.picture-in-picture.templates.button"
></ytmd-trans>
</div>
</div>
</div>