mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-17 05:02:06 +00:00
QOL: Move source code under the src directory. (#1318)
This commit is contained in:
111
src/plugins/picture-in-picture/back.ts
Normal file
111
src/plugins/picture-in-picture/back.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import style from './style.css';
|
||||
|
||||
import { injectCSS } from '../utils';
|
||||
import { setOptions as setPluginOptions } from '../../config/plugins';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
let isInPiP = false;
|
||||
let originalPosition: number[];
|
||||
let originalSize: number[];
|
||||
let originalFullScreen: boolean;
|
||||
let originalMaximized: boolean;
|
||||
|
||||
let win: BrowserWindow;
|
||||
|
||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
||||
|
||||
let options: Partial<PiPOptions>;
|
||||
|
||||
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
|
||||
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
|
||||
|
||||
const setLocalOptions = (_options: Partial<PiPOptions>) => {
|
||||
options = { ...options, ..._options };
|
||||
setPluginOptions('picture-in-picture', _options);
|
||||
};
|
||||
|
||||
const togglePiP = () => {
|
||||
isInPiP = !isInPiP;
|
||||
setLocalOptions({ isInPiP });
|
||||
|
||||
if (isInPiP) {
|
||||
originalFullScreen = win.isFullScreen();
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(false);
|
||||
}
|
||||
|
||||
originalMaximized = win.isMaximized();
|
||||
if (originalMaximized) {
|
||||
win.unmaximize();
|
||||
}
|
||||
|
||||
originalPosition = win.getPosition();
|
||||
originalSize = win.getSize();
|
||||
|
||||
win.webContents.on('before-input-event', blockShortcutsInPiP);
|
||||
|
||||
win.setMaximizable(false);
|
||||
win.setFullScreenable(false);
|
||||
|
||||
win.webContents.send('pip-toggle', true);
|
||||
|
||||
app.dock?.hide();
|
||||
win.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
app.dock?.show();
|
||||
if (options.alwaysOnTop) {
|
||||
win.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
}
|
||||
} else {
|
||||
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
||||
win.setMaximizable(true);
|
||||
win.setFullScreenable(true);
|
||||
|
||||
win.webContents.send('pip-toggle', false);
|
||||
|
||||
win.setVisibleOnAllWorkspaces(false);
|
||||
win.setAlwaysOnTop(false);
|
||||
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(true);
|
||||
}
|
||||
|
||||
if (originalMaximized) {
|
||||
win.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
||||
const [w, h] = isInPiP ? pipSize() : originalSize;
|
||||
win.setPosition(x, y);
|
||||
win.setSize(w, h);
|
||||
|
||||
win.setWindowButtonVisibility?.(!isInPiP);
|
||||
};
|
||||
|
||||
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
||||
const key = input.key.toLowerCase();
|
||||
|
||||
if (key === 'f') {
|
||||
event.preventDefault();
|
||||
} else if (key === 'escape') {
|
||||
togglePiP();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
export default (_win: BrowserWindow, _options: PiPOptions) => {
|
||||
options ??= _options;
|
||||
win ??= _win;
|
||||
setLocalOptions({ isInPiP });
|
||||
injectCSS(win.webContents, style);
|
||||
ipcMain.on('picture-in-picture', () => {
|
||||
togglePiP();
|
||||
});
|
||||
};
|
||||
|
||||
export const setOptions = setLocalOptions;
|
||||
179
src/plugins/picture-in-picture/front.ts
Normal file
179
src/plugins/picture-in-picture/front.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
|
||||
import keyEventAreEqual from 'keyboardevents-areequal';
|
||||
|
||||
import pipHTML from './templates/picture-in-picture.html';
|
||||
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
|
||||
import { ElementFromHtml } from '../utils';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
||||
|
||||
function $<E extends Element = Element>(selector: string) {
|
||||
return document.querySelector<E>(selector);
|
||||
}
|
||||
|
||||
let useNativePiP = false;
|
||||
let menu: Element | null = null;
|
||||
const pipButton = ElementFromHtml(pipHTML);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const menuUrl = $<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(pipButton);
|
||||
});
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.send('picture-in-picture');
|
||||
return false;
|
||||
};
|
||||
// For UI (HTML)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
||||
(global as any).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');
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function observeMenu(options: PiPOptions) {
|
||||
useNativePiP = options.useNativePiP;
|
||||
document.addEventListener(
|
||||
'apiLoaded',
|
||||
() => {
|
||||
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,
|
||||
});
|
||||
},
|
||||
{ once: true, passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
export default (options: PiPOptions) => {
|
||||
observeMenu(options);
|
||||
|
||||
if (options.hotkey) {
|
||||
const hotkeyEvent = toKeyEvent(options.hotkey);
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (
|
||||
keyEventAreEqual(event, hotkeyEvent)
|
||||
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
|
||||
) {
|
||||
togglePictureInPicture();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
12
src/plugins/picture-in-picture/keyboardevent-from-electron-accelerator.d.ts
vendored
Normal file
12
src/plugins/picture-in-picture/keyboardevent-from-electron-accelerator.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
declare module 'keyboardevent-from-electron-accelerator' {
|
||||
interface KeyboardEvent {
|
||||
key?: string;
|
||||
code?: string;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
|
||||
export const toKeyEvent: (accelerator: string) => KeyboardEvent;
|
||||
}
|
||||
14
src/plugins/picture-in-picture/keyboardevents-areequal.d.ts
vendored
Normal file
14
src/plugins/picture-in-picture/keyboardevents-areequal.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
declare module 'keyboardevents-areequal' {
|
||||
interface KeyboardEvent {
|
||||
key?: string;
|
||||
code?: string;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
|
||||
const areEqual: (event1: KeyboardEvent, event2: KeyboardEvent) => boolean;
|
||||
|
||||
export default areEqual;
|
||||
}
|
||||
75
src/plugins/picture-in-picture/menu.ts
Normal file
75
src/plugins/picture-in-picture/menu.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { setOptions } from './back';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
|
||||
import { MenuTemplate } from '../../menu';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [
|
||||
{
|
||||
label: 'Always on top',
|
||||
type: 'checkbox',
|
||||
checked: options.alwaysOnTop,
|
||||
click(item) {
|
||||
setOptions({ alwaysOnTop: item.checked });
|
||||
win.setAlwaysOnTop(item.checked);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Save window position',
|
||||
type: 'checkbox',
|
||||
checked: options.savePosition,
|
||||
click(item) {
|
||||
setOptions({ savePosition: item.checked });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Save window size',
|
||||
type: 'checkbox',
|
||||
checked: options.saveSize,
|
||||
click(item) {
|
||||
setOptions({ saveSize: item.checked });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hotkey',
|
||||
type: 'checkbox',
|
||||
checked: !!options.hotkey,
|
||||
async click(item) {
|
||||
const output = await prompt({
|
||||
title: 'Picture in Picture Hotkey',
|
||||
label: 'Choose a hotkey for toggling Picture in Picture',
|
||||
type: 'keybind',
|
||||
keybindOptions: [{
|
||||
value: 'hotkey',
|
||||
label: 'Hotkey',
|
||||
default: options.hotkey,
|
||||
}],
|
||||
...promptOptions(),
|
||||
}, win);
|
||||
|
||||
if (output) {
|
||||
const { value, accelerator } = output[0];
|
||||
setOptions({ [value]: accelerator });
|
||||
|
||||
item.checked = !!accelerator;
|
||||
} else {
|
||||
// Reset checkbox if prompt was canceled
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Use native PiP',
|
||||
type: 'checkbox',
|
||||
checked: options.useNativePiP,
|
||||
click(item) {
|
||||
setOptions({ useNativePiP: item.checked });
|
||||
},
|
||||
},
|
||||
];
|
||||
43
src/plugins/picture-in-picture/style.css
Normal file
43
src/plugins/picture-in-picture/style.css
Normal file
@ -0,0 +1,43 @@
|
||||
/* improve visibility of the player bar elements */
|
||||
ytmusic-app-layout.pip ytmusic-player-bar svg,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar .time-info,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar yt-formatted-string,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar .yt-formatted-string {
|
||||
filter: drop-shadow(2px 4px 6px black);
|
||||
color: white !important;
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
/* improve the style of the player bar expanding menu */
|
||||
ytmusic-app-layout.pip ytmusic-player-expanding-menu {
|
||||
border-radius: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px) brightness(20%);
|
||||
}
|
||||
|
||||
/* fix volumeHud position when both in-app-menu and PiP are active */
|
||||
.cet-container ytmusic-app-layout.pip #volumeHud {
|
||||
top: 22px !important;
|
||||
}
|
||||
|
||||
/* make player-bar not draggable if in-app-menu is enabled */
|
||||
.cet-container ytmusic-app-layout.pip ytmusic-player-bar {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
/* make player draggable if in-app-menu is enabled */
|
||||
.cet-container ytmusic-app-layout.pip #player {
|
||||
-webkit-app-region: drag !important;
|
||||
}
|
||||
|
||||
/* remove info, thumbnail and menu from player-bar */
|
||||
ytmusic-app-layout.pip ytmusic-player-bar .content-info-wrapper,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar .thumbnail-image-wrapper,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar ytmusic-menu-renderer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* disable the video-toggle button when in PiP mode */
|
||||
ytmusic-app-layout.pip .video-switch-button {
|
||||
display: none !important;
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
<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 menu-icon 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"
|
||||
>
|
||||
Picture in picture
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user