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

@ -298,6 +298,7 @@
"serve": "14.2.4",
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
"socks": "2.8.5",
"solid-element": "1.9.1",
"solid-floating-ui": "0.3.1",
"solid-js": "1.9.7",
"solid-styled-components": "0.28.5",

18
pnpm-lock.yaml generated
View File

@ -208,6 +208,9 @@ importers:
socks:
specifier: 2.8.5
version: 2.8.5
solid-element:
specifier: 1.9.1
version: 1.9.1(solid-js@1.9.7)
solid-floating-ui:
specifier: 0.3.1
version: 0.3.1(@floating-ui/dom@1.7.2)(solid-js@1.9.7)
@ -1976,6 +1979,9 @@ packages:
resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==}
engines: {node: '>=0.10.0'}
component-register@0.8.7:
resolution: {integrity: sha512-clPS/o1RNfJw7L1/w4q+nkj6l7JV32kFHCx6vW5nSPOEly4B9olMeADNilEgpLV/DdeS7y8JXhHKx9YvSj8vqQ==}
compressible@2.0.18:
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
engines: {node: '>= 0.6'}
@ -4280,6 +4286,11 @@ packages:
resolution: {integrity: sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
solid-element@1.9.1:
resolution: {integrity: sha512-baJy6Qz27oAUgkPlqOf3Y+7RsBiuVQrS51Nrh1ddDbrqrNPvJbIvehpUsTzLNFb2ZHIoHuNnDg330go/ZKcRdg==}
peerDependencies:
solid-js: ^1.9.3
solid-floating-ui@0.3.1:
resolution: {integrity: sha512-o/QmGsWPS2Z3KidAxP0nDvN7alI7Kqy0kU+wd85Fz+au5SYcnYm7I6Fk3M60Za35azsPX0U+5fEtqfOuk6Ao0Q==}
engines: {node: '>=10'}
@ -6644,6 +6655,8 @@ snapshots:
compare-version@0.1.2: {}
component-register@0.8.7: {}
compressible@2.0.18:
dependencies:
mime-db: 1.54.0
@ -9218,6 +9231,11 @@ snapshots:
ip-address: 9.0.5
smart-buffer: 4.2.0
solid-element@1.9.1(solid-js@1.9.7):
dependencies:
component-register: 0.8.7
solid-js: 1.9.7
solid-floating-ui@0.3.1(@floating-ui/dom@1.7.2)(solid-js@1.9.7):
dependencies:
'@floating-ui/dom': 1.7.2

View File

@ -5,7 +5,7 @@ import defaults from './defaults';
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
// prettier-ignore
export type IStore = InstanceType<typeof import('conf/dist/source/index').default<Record<string, unknown>>>;
export type IStore = InstanceType<typeof import('conf').default<Record<string, unknown>>>;
const migrations = {
'>=3.3.0'(store: IStore) {

View File

@ -859,7 +859,7 @@
},
"name": "تفعيل الفيديو",
"templates": {
"button": "أغنية"
"button-song": "أغنية"
}
},
"visualizer": {

View File

@ -835,7 +835,7 @@
},
"name": "Превключване на видео",
"templates": {
"button": "Песен"
"button-song": "Песен"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Botó de vídeo",
"templates": {
"button": "Cançó"
"button-song": "Cançó"
}
},
"visualizer": {

View File

@ -765,7 +765,7 @@
},
"name": "Přepínač videa",
"templates": {
"button": "Písnička"
"button-song": "Písnička"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Videoumschalter",
"templates": {
"button": "Lied"
"button-song": "Lied"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Εναλλαγή βίντεο",
"templates": {
"button": "Τραγούδι"
"button-song": "Τραγούδι"
}
},
"visualizer": {

View File

@ -381,6 +381,11 @@
},
"templates": {
"title": "Open captions selector"
},
"toast": {
"caption-changed": "Caption changed to {{language}}",
"caption-disabled": "Captions disabled",
"no-captions": "No captions available for this song"
}
},
"compact-sidebar": {
@ -600,7 +605,15 @@
},
"navigation": {
"description": "Next/Back navigation arrows directly integrated in the interface, like in your favorite browser",
"name": "Navigation"
"name": "Navigation",
"templates": {
"back": {
"title": "Go to previous page"
},
"forward": {
"title": "Go to next page"
}
}
},
"no-google-login": {
"description": "Remove Google login buttons and links from the interface",
@ -691,6 +704,11 @@
}
}
},
"renderer": {
"quality-settings-button": {
"label": "Open player quality changer"
}
},
"description": "Allows changing the video quality with a button on the video overlay",
"name": "Video Quality Changer"
},
@ -859,7 +877,8 @@
},
"name": "Video Toggle",
"templates": {
"button": "Song"
"button-song": "Song",
"button-video": "Video"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Alternador de vídeo",
"templates": {
"button": "Canción"
"button-song": "Canción"
}
},
"visualizer": {

View File

@ -831,7 +831,7 @@
},
"name": "ویدیو به آهنگ",
"templates": {
"button": "ترانه"
"button-song": "ترانه"
}
},
"visualizer": {

View File

@ -784,7 +784,7 @@
}
},
"templates": {
"button": "Kanta"
"button-song": "Kanta"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Basculer la vidéo",
"templates": {
"button": "Musique"
"button-song": "Musique"
}
},
"visualizer": {

View File

@ -831,7 +831,7 @@
},
"name": "Videó váltó",
"templates": {
"button": "Zeneszám"
"button-song": "Zeneszám"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Peralih Video",
"templates": {
"button": "Lagu"
"button-song": "Lagu"
}
},
"visualizer": {

View File

@ -799,7 +799,7 @@
},
"name": "Myndbandsrofi",
"templates": {
"button": "Lag"
"button-song": "Lag"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Selettore Brano/Video",
"templates": {
"button": "Brano"
"button-song": "Brano"
}
},
"visualizer": {

View File

@ -857,7 +857,7 @@
},
"name": "動画の切り替え",
"templates": {
"button": "曲"
"button-song": "曲"
}
},
"visualizer": {

View File

@ -859,7 +859,8 @@
},
"name": "영상 전환",
"templates": {
"button": "노래"
"button-song": "노래",
"button-video": "영상"
}
},
"visualizer": {

View File

@ -618,7 +618,7 @@
},
"name": "Vaizdo įrašo perjungimas",
"templates": {
"button": "Daina"
"button-song": "Daina"
}
},
"visualizer": {

View File

@ -381,7 +381,7 @@
}
},
"templates": {
"button": "Lagu"
"button-song": "Lagu"
}
}
}

View File

@ -577,7 +577,7 @@
},
"name": "Videoveksling",
"templates": {
"button": "Spor"
"button-song": "Spor"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "भिडियो टगल",
"templates": {
"button": "गीत"
"button-song": "गीत"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Videoschakelaar",
"templates": {
"button": "Nummer"
"button-song": "Nummer"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Przełącznik wideo",
"templates": {
"button": "Utwór"
"button-song": "Utwór"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Alternar vídeo",
"templates": {
"button": "Música"
"button-song": "Música"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Botão de Alternar Vídeo",
"templates": {
"button": "Música"
"button-song": "Música"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Comutator video",
"templates": {
"button": "Melodie"
"button-song": "Melodie"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Переключатель видео",
"templates": {
"button": "Песня"
"button-song": "Песня"
}
},
"visualizer": {

View File

@ -222,7 +222,7 @@
}
},
"templates": {
"button": "Låt"
"button-song": "Låt"
}
}
}

View File

@ -859,7 +859,7 @@
},
"name": "வீடியோ மாற்று",
"templates": {
"button": "பாடல்"
"button-song": "பாடல்"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "ปุ่มวิดีโอ",
"templates": {
"button": "เพลง"
"button-song": "เพลง"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Video Geçiş",
"templates": {
"button": "Şarkı"
"button-song": "Şarkı"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "Перемикач відео",
"templates": {
"button": "Пісня"
"button-song": "Пісня"
}
},
"visualizer": {

View File

@ -857,7 +857,7 @@
},
"name": "Chuyển đổi video",
"templates": {
"button": "Bài hát"
"button-song": "Bài hát"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "视频切换开关",
"templates": {
"button": "歌曲"
"button-song": "歌曲"
}
},
"visualizer": {

View File

@ -859,7 +859,7 @@
},
"name": "歌曲/影片切換",
"templates": {
"button": "歌曲"
"button-song": "歌曲"
}
},
"visualizer": {

View File

@ -763,7 +763,7 @@ app.whenReady().then(async () => {
const splited = decodeURIComponent(command).split(' ');
handleProtocol(splited.shift()!, splited);
handleProtocol(splited.shift()!, ...splited);
return;
}

View File

@ -1,12 +1,17 @@
import { render } from 'solid-js/web';
import { createSignal, Show } from 'solid-js';
import { t } from '@/i18n';
import { createPlugin } from '@/utils';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { waitForElement } from '@/utils/wait-for-element';
import undislikeHTML from './templates/undislike.html?raw';
import dislikeHTML from './templates/dislike.html?raw';
import likeHTML from './templates/like.html?raw';
import unlikeHTML from './templates/unlike.html?raw';
import {
DislikeButton,
LikeButton,
UnDislikeButton,
UnLikeButton,
} from './templates';
export default createPlugin<
unknown,
@ -52,19 +57,69 @@ export default createPlugin<
}
const continuations = await waitForElement<HTMLElement>('#continuations');
this.waiting = false;
//Gets the for buttons
const buttons: Array<HTMLElement> = [
ElementFromHtml(undislikeHTML),
ElementFromHtml(dislikeHTML),
ElementFromHtml(likeHTML),
ElementFromHtml(unlikeHTML),
];
const [showUnDislike, setShowUnDislike] = createSignal(true);
const [showDislike, setShowDislike] = createSignal(true);
const [showLike, setShowLike] = createSignal(true);
const [showUnLike, setShowUnLike] = createSignal(true);
const DEFAULT_MASK_SIZE = '100% 50%';
const [unDislikeMaskSize, setUnDislikeMaskSize] =
createSignal(DEFAULT_MASK_SIZE);
const [dislikeMaskSize, setDislikeMaskSize] =
createSignal(DEFAULT_MASK_SIZE);
const [likeMaskSize, setLikeMaskSize] = createSignal(DEFAULT_MASK_SIZE);
const [unLikeMaskSize, setUnLikeMaskSize] =
createSignal(DEFAULT_MASK_SIZE);
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.flexDirection = 'row';
render(
() => (
<>
<Show when={showUnDislike()}>
<UnDislikeButton
onClick={this.loadFullList}
maskSize={unDislikeMaskSize()}
/>
</Show>
<Show when={showDislike()}>
<DislikeButton
onClick={this.loadFullList}
maskSize={dislikeMaskSize()}
/>
</Show>
<Show when={showLike()}>
<LikeButton
onClick={this.loadFullList}
maskSize={likeMaskSize()}
/>
</Show>
<Show when={showUnLike()}>
<UnLikeButton
onClick={this.loadFullList}
maskSize={unLikeMaskSize()}
/>
</Show>
</>
),
buttonContainer,
);
//Finds the playlist
const playlist =
document.querySelector('ytmusic-playlist-shelf-renderer') ??
Array.prototype.at.call(document.querySelectorAll('ytmusic-shelf-renderer'), -1)!;
document.querySelector(':nth-last-child(1 of ytmusic-shelf-renderer)');
if (!playlist) {
return;
}
// Adds an observer for every button, so it gets updated when one is clicked
this.changeObserver?.disconnect();
this.changeObserver = new MutationObserver(() => {
this.stop();
this.start();
@ -84,34 +139,57 @@ export default createPlugin<
'#button-shape-dislike > button',
).length;
if (continuations.children.length == 0 && listsLength > 0) {
const counts = [
playlist?.querySelectorAll(
const counts = {
dislike: playlist?.querySelectorAll(
'#button-shape-dislike > button[aria-pressed=true]',
).length,
playlist?.querySelectorAll(
undislike: playlist?.querySelectorAll(
'#button-shape-dislike > button[aria-pressed=false]',
).length,
playlist?.querySelectorAll(
unlike: playlist?.querySelectorAll(
'#button-shape-like > button[aria-pressed=false]',
).length,
playlist?.querySelectorAll(
like: playlist?.querySelectorAll(
'#button-shape-like > button[aria-pressed=true]',
).length,
];
let i = 0;
for (const count of counts) {
if (count == 0) {
buttons.splice(i, 1);
i--;
} else {
(
buttons[i].children[0].children[0] as HTMLElement
).style.setProperty(
'-webkit-mask-size',
`100% ${100 - (count / listsLength) * 100}%`,
);
};
for (const [name, size] of Object.entries(counts)) {
switch (name) {
case 'dislike':
if (size > 0) {
setShowDislike(true);
setDislikeMaskSize(`100% ${100 - (size / listsLength) * 100}%`);
} else {
setShowDislike(false);
}
break;
case 'undislike':
if (size > 0) {
setShowUnDislike(true);
setUnDislikeMaskSize(
`100% ${100 - (size / listsLength) * 100}%`,
);
} else {
setShowUnDislike(false);
}
break;
case 'like':
if (size > 0) {
setShowLike(true);
setLikeMaskSize(`100% ${100 - (size / listsLength) * 100}%`);
} else {
setShowLike(false);
}
break;
case 'unlike':
if (size > 0) {
setShowUnLike(true);
setUnLikeMaskSize(`100% ${100 - (size / listsLength) * 100}%`);
} else {
setShowUnLike(false);
}
break;
}
i++;
}
}
const menuParent =
@ -126,10 +204,7 @@ export default createPlugin<
menu,
menuParent.children[menuParent.children.length - 1],
);
for (const button of buttons) {
menu.appendChild(button);
button.addEventListener('click', this.loadFullList);
}
menu.appendChild(buttonContainer);
}
},
loadFullList(event: MouseEvent) {
@ -159,7 +234,7 @@ export default createPlugin<
let playlistButtons: NodeListOf<HTMLElement> | undefined;
const playlist =
document.querySelector('ytmusic-playlist-shelf-renderer') ??
Array.prototype.at.call(document.querySelectorAll('ytmusic-shelf-renderer'), -1)!;
document.querySelector(':nth-last-child(1 of ytmusic-shelf-renderer)');
switch (id) {
case 'allundislike':
playlistButtons = playlist?.querySelectorAll(

View File

@ -0,0 +1,99 @@
export interface DislikeButtonProps {
onClick?: (e: MouseEvent) => void;
maskSize: string;
}
export const DislikeButton = (props: DislikeButtonProps) => (
<div class="style-scope">
<button
id="alldislike"
data-type="dislike"
data-filled="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Dislike all"
onClick={props.onClick}
>
<div
class="yt-spec-button-shape-next__icon"
style={{
'color': 'var(--ytmusic-setting-item-toggle-active)',
}}
aria-hidden="true"
>
<div
class="yt-spec-button-shape-next__icon"
style={{
'color': 'white',
'mask': 'linear-gradient(grey, grey)',
'-webkit-mask': 'linear-gradient(grey, grey)',
'-webkit-mask-size': props.maskSize,
'-webkit-mask-repeat': 'no-repeat',
'z-index': 1,
'position': 'absolute',
}}
aria-hidden="true"
>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
>
<g class="style-scope yt-icon">
<path
d="M18,4h3v10h-3V4z M5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21c0.58,0,1.14-0.24,1.52-0.65L17,14V4H6.57 C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<div
style={{
width: '24px',
height: '24px',
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
>
<g class="style-scope yt-icon">
<path
d="M18,4h3v10h-3V4z M5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21c0.58,0,1.14-0.24,1.52-0.65L17,14V4H6.57 C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<yt-touch-feedback-shape
style={{
'border-radius': 'inherit',
}}
>
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
>
<div class="yt-spec-touch-feedback-shape__stroke"></div>
<div class="yt-spec-touch-feedback-shape__fill"></div>
</div>
</yt-touch-feedback-shape>
</button>
</div>
);

View File

@ -1,76 +0,0 @@
<div class="style-scope">
<button
id="alldislike"
data-type="dislike"
data-filled="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Dislike all"
>
<div
class="yt-spec-button-shape-next__icon"
style="color: var(--ytmusic-setting-item-toggle-active)"
aria-hidden="true"
>
<div
class="yt-spec-button-shape-next__icon"
style="
color: white;
-webkit-mask: linear-gradient(grey, grey);
-webkit-mask-size: 100% 50%;
-webkit-mask-repeat: no-repeat;
z-index: 1;
position: absolute;
"
aria-hidden="true"
>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="
pointer-events: none;
display: block;
width: 100%;
height: 100%;
"
>
<g class="style-scope yt-icon">
<path
d="M18,4h3v10h-3V4z M5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21c0.58,0,1.14-0.24,1.52-0.65L17,14V4H6.57 C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%"
>
<g class="style-scope yt-icon">
<path
d="M18,4h3v10h-3V4z M5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21c0.58,0,1.14-0.24,1.52-0.65L17,14V4H6.57 C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<yt-touch-feedback-shape style="border-radius: inherit">
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
>
<div class="yt-spec-touch-feedback-shape__stroke"></div>
<div class="yt-spec-touch-feedback-shape__fill"></div>
</div>
</yt-touch-feedback-shape>
</button>
</div>

View File

@ -0,0 +1,4 @@
export * from './like-button';
export * from './dislike-button';
export * from './undislike-button';
export * from './unlike-button';

View File

@ -0,0 +1,90 @@
export interface LikeButtonProps {
onClick?: (e: MouseEvent) => void;
maskSize: string;
}
export const LikeButton = (props: LikeButtonProps) => (
<div class="style-scope">
<button
id="alllike"
data-type="like"
data-filled="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Like all"
onClick={props.onClick}
>
<div
class="yt-spec-button-shape-next__icon"
style={{
'color': 'var(--ytmusic-setting-item-toggle-active)',
}}
aria-hidden="true"
>
<div
class="yt-spec-button-shape-next__icon"
style={{
'color': 'white',
'mask': 'linear-gradient(grey, grey)',
'-webkit-mask': 'linear-gradient(grey, grey)',
'-webkit-mask-size': props.maskSize,
'-webkit-mask-repeat': 'no-repeat',
'z-index': 1,
'position': 'absolute',
}}
aria-hidden="true"
>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
>
<g class="style-scope yt-icon">
<path
d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
>
<g class="style-scope yt-icon">
<path
d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<yt-touch-feedback-shape style="border-radius: inherit">
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
>
<div class="yt-spec-touch-feedback-shape__stroke"></div>
<div class="yt-spec-touch-feedback-shape__fill"></div>
</div>
</yt-touch-feedback-shape>
</button>
</div>
);

View File

@ -1,76 +0,0 @@
<div class="style-scope">
<button
id="alllike"
data-type="like"
data-filled="false"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Like all"
>
<div
class="yt-spec-button-shape-next__icon"
style="color: var(--ytmusic-setting-item-toggle-active)"
aria-hidden="true"
>
<div
class="yt-spec-button-shape-next__icon"
style="
color: white;
-webkit-mask: linear-gradient(grey, grey);
-webkit-mask-size: 100% 50%;
-webkit-mask-repeat: no-repeat;
z-index: 1;
position: absolute;
"
aria-hidden="true"
>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="
pointer-events: none;
display: block;
width: 100%;
height: 100%;
"
>
<g class="style-scope yt-icon">
<path
d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%"
>
<g class="style-scope yt-icon">
<path
d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<yt-touch-feedback-shape style="border-radius: inherit">
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
>
<div class="yt-spec-touch-feedback-shape__stroke"></div>
<div class="yt-spec-touch-feedback-shape__fill"></div>
</div>
</yt-touch-feedback-shape>
</button>
</div>

View File

@ -0,0 +1,104 @@
export interface UnDislikeButtonProps {
onClick?: (e: MouseEvent) => void;
maskSize: string;
}
export const UnDislikeButton = (props: UnDislikeButtonProps) => (
<div class="style-scope">
<button
id="allundislike"
data-type="dislike"
data-filled="true"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Undislike all"
onClick={props.onClick}
>
<div
class="yt-spec-button-shape-next__icon"
style={{
color: 'var(--ytmusic-setting-item-toggle-active)',
}}
aria-hidden="true"
>
<div
class="yt-spec-button-shape-next__icon"
style={{
'color': 'white',
'mask': 'linear-gradient(grey, grey)',
'-webkit-mask': 'linear-gradient(grey, grey)',
'-webkit-mask-size': props.maskSize,
'-webkit-mask-repeat': 'no-repeat',
'z-index': 1,
'position': 'absolute',
}}
aria-hidden="true"
>
<div
style={{
width: '24px',
height: '24px',
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
>
<g class="style-scope yt-icon">
<path
d="M17,4h-1H6.57C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21 c0.58,0,1.14-0.24,1.52-0.65L17,14h4V4H17z M10.4,19.67C10.21,19.88,9.92,20,9.62,20c-0.26,0-0.5-0.11-0.63-0.3 c-0.07-0.1-0.15-0.26-0.09-0.47l1.52-4.94l0.4-1.29H9.46H5.23c-0.41,0-0.8-0.17-1.03-0.46c-0.12-0.15-0.25-0.4-0.18-0.72l1.34-6 C5.46,5.35,5.97,5,6.57,5H16v8.61L10.4,19.67z M20,13h-3V5h3V13z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<div
style={{
width: '24px',
height: '24px',
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
>
<g class="style-scope yt-icon">
<path
d="M17,4h-1H6.57C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21 c0.58,0,1.14-0.24,1.52-0.65L17,14h4V4H17z M10.4,19.67C10.21,19.88,9.92,20,9.62,20c-0.26,0-0.5-0.11-0.63-0.3 c-0.07-0.1-0.15-0.26-0.09-0.47l1.52-4.94l0.4-1.29H9.46H5.23c-0.41,0-0.8-0.17-1.03-0.46c-0.12-0.15-0.25-0.4-0.18-0.72l1.34-6 C5.46,5.35,5.97,5,6.57,5H16v8.61L10.4,19.67z M20,13h-3V5h3V13z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<yt-touch-feedback-shape
style={{
'border-radius': 'inherit',
}}
>
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
>
<div class="yt-spec-touch-feedback-shape__stroke"></div>
<div class="yt-spec-touch-feedback-shape__fill"></div>
</div>
</yt-touch-feedback-shape>
</button>
</div>
);

View File

@ -1,76 +0,0 @@
<div class="style-scope">
<button
id="allundislike"
data-type="dislike"
data-filled="true"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Undislike all"
>
<div
class="yt-spec-button-shape-next__icon"
style="color: var(--ytmusic-setting-item-toggle-active)"
aria-hidden="true"
>
<div
class="yt-spec-button-shape-next__icon"
style="
color: white;
-webkit-mask: linear-gradient(grey, grey);
-webkit-mask-size: 100% 50%;
-webkit-mask-repeat: no-repeat;
z-index: 1;
position: absolute;
"
aria-hidden="true"
>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="
pointer-events: none;
display: block;
width: 100%;
height: 100%;
"
>
<g class="style-scope yt-icon">
<path
d="M17,4h-1H6.57C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21 c0.58,0,1.14-0.24,1.52-0.65L17,14h4V4H17z M10.4,19.67C10.21,19.88,9.92,20,9.62,20c-0.26,0-0.5-0.11-0.63-0.3 c-0.07-0.1-0.15-0.26-0.09-0.47l1.52-4.94l0.4-1.29H9.46H5.23c-0.41,0-0.8-0.17-1.03-0.46c-0.12-0.15-0.25-0.4-0.18-0.72l1.34-6 C5.46,5.35,5.97,5,6.57,5H16v8.61L10.4,19.67z M20,13h-3V5h3V13z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%"
>
<g class="style-scope yt-icon">
<path
d="M17,4h-1H6.57C5.5,4,4.59,4.67,4.38,5.61l-1.34,6C2.77,12.85,3.82,14,5.23,14h4.23l-1.52,4.94C7.62,19.97,8.46,21,9.62,21 c0.58,0,1.14-0.24,1.52-0.65L17,14h4V4H17z M10.4,19.67C10.21,19.88,9.92,20,9.62,20c-0.26,0-0.5-0.11-0.63-0.3 c-0.07-0.1-0.15-0.26-0.09-0.47l1.52-4.94l0.4-1.29H9.46H5.23c-0.41,0-0.8-0.17-1.03-0.46c-0.12-0.15-0.25-0.4-0.18-0.72l1.34-6 C5.46,5.35,5.97,5,6.57,5H16v8.61L10.4,19.67z M20,13h-3V5h3V13z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<yt-touch-feedback-shape style="border-radius: inherit">
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
>
<div class="yt-spec-touch-feedback-shape__stroke"></div>
<div class="yt-spec-touch-feedback-shape__fill"></div>
</div>
</yt-touch-feedback-shape>
</button>
</div>

View File

@ -0,0 +1,104 @@
export interface UnLikeButtonProps {
onClick?: (e: MouseEvent) => void;
maskSize: string;
}
export const UnLikeButton = (props: UnLikeButtonProps) => (
<div class="style-scope">
<button
id="allunlike"
data-type="like"
data-filled="true"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Unlike all"
onClick={props.onClick}
>
<div
class="yt-spec-button-shape-next__icon"
style={{
'color': 'var(--ytmusic-setting-item-toggle-active)',
}}
aria-hidden="true"
>
<div
class="yt-spec-button-shape-next__icon"
style={{
'color': 'white',
'mask': 'linear-gradient(grey, grey)',
'-webkit-mask': 'linear-gradient(grey, grey)',
'-webkit-mask-size': props.maskSize,
'-webkit-mask-repeat': 'no-repeat',
'z-index': 1,
'position': 'absolute',
}}
aria-hidden="true"
>
<div
style={{
width: '24px',
height: '24px',
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
>
<g class="style-scope yt-icon">
<path
d="M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11H3v10h4h1h9.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z M7,20H4v-8h3V20z M19.98,13.17l-1.34,6 C18.54,19.65,18.03,20,17.43,20H8v-8.61l5.6-6.06C13.79,5.12,14.08,5,14.38,5c0.26,0,0.5,0.11,0.63,0.3 c0.07,0.1,0.15,0.26,0.09,0.47l-1.52,4.94L13.18,12h1.35h4.23c0.41,0,0.8,0.17,1.03,0.46C19.92,12.61,20.05,12.86,19.98,13.17z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<div
style={{
width: '24px',
height: '24px',
}}
>
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
class="style-scope yt-icon"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
>
<g class="style-scope yt-icon">
<path
d="M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11H3v10h4h1h9.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z M7,20H4v-8h3V20z M19.98,13.17l-1.34,6 C18.54,19.65,18.03,20,17.43,20H8v-8.61l5.6-6.06C13.79,5.12,14.08,5,14.38,5c0.26,0,0.5,0.11,0.63,0.3 c0.07,0.1,0.15,0.26,0.09,0.47l-1.52,4.94L13.18,12h1.35h4.23c0.41,0,0.8,0.17,1.03,0.46C19.92,12.61,20.05,12.86,19.98,13.17z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<yt-touch-feedback-shape
style={{
'border-radius': 'inherit',
}}
>
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
>
<div class="yt-spec-touch-feedback-shape__stroke"></div>
<div class="yt-spec-touch-feedback-shape__fill"></div>
</div>
</yt-touch-feedback-shape>
</button>
</div>
);

View File

@ -1,76 +0,0 @@
<div class="style-scope">
<button
id="allunlike"
data-type="like"
data-filled="true"
class="like-menu yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-button"
aria-pressed="false"
aria-label="Unlike all"
>
<div
class="yt-spec-button-shape-next__icon"
style="color: var(--ytmusic-setting-item-toggle-active)"
aria-hidden="true"
>
<div
class="yt-spec-button-shape-next__icon"
style="
color: white;
-webkit-mask: linear-gradient(grey, grey);
-webkit-mask-size: 100% 50%;
-webkit-mask-repeat: no-repeat;
z-index: 1;
position: absolute;
"
aria-hidden="true"
>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="
pointer-events: none;
display: block;
width: 100%;
height: 100%;
"
>
<g class="style-scope yt-icon">
<path
d="M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11H3v10h4h1h9.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z M7,20H4v-8h3V20z M19.98,13.17l-1.34,6 C18.54,19.65,18.03,20,17.43,20H8v-8.61l5.6-6.06C13.79,5.12,14.08,5,14.38,5c0.26,0,0.5,0.11,0.63,0.3 c0.07,0.1,0.15,0.26,0.09,0.47l-1.52,4.94L13.18,12h1.35h4.23c0.41,0,0.8,0.17,1.03,0.46C19.92,12.61,20.05,12.86,19.98,13.17z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<div style="width: 24px; height: 24px">
<svg
viewBox="0 0 24 24"
preserveAspectRatio="xMidYMid meet"
focusable="false"
class="style-scope yt-icon"
style="pointer-events: none; display: block; width: 100%; height: 100%"
>
<g class="style-scope yt-icon">
<path
d="M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11H3v10h4h1h9.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z M7,20H4v-8h3V20z M19.98,13.17l-1.34,6 C18.54,19.65,18.03,20,17.43,20H8v-8.61l5.6-6.06C13.79,5.12,14.08,5,14.38,5c0.26,0,0.5,0.11,0.63,0.3 c0.07,0.1,0.15,0.26,0.09,0.47l-1.52,4.94L13.18,12h1.35h4.23c0.41,0,0.8,0.17,1.03,0.46C19.92,12.61,20.05,12.86,19.98,13.17z"
class="style-scope yt-icon"
></path>
</g>
</svg>
</div>
</div>
<yt-touch-feedback-shape style="border-radius: inherit">
<div
class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response"
aria-hidden="true"
>
<div class="yt-spec-touch-feedback-shape__stroke"></div>
<div class="yt-spec-touch-feedback-shape__fill"></div>
</div>
</yt-touch-feedback-shape>
</button>
</div>

View File

@ -7,7 +7,7 @@ import { t } from '@/i18n';
export default createBackend({
start({ ipc: { handle }, window }) {
handle(
'captionsSelector',
'ytmd:captions-selector',
async (captionLabels: Record<string, string>, currentIndex: string) =>
await prompt(
{

View File

@ -1,21 +1,20 @@
import { createPlugin } from '@/utils';
import { YoutubePlayer } from '@/types/youtube-player';
import { t } from '@/i18n';
import backend from './back';
import renderer, { CaptionsSelectorConfig, LanguageOptions } from './renderer';
import { t } from '@/i18n';
import type { YoutubePlayer } from '@/types/youtube-player';
export default createPlugin<
unknown,
unknown,
{
captionsSettingsButton: HTMLElement;
captionsSettingsButton?: HTMLElement;
captionTrackList: LanguageOptions[] | null;
api: YoutubePlayer | null;
config: CaptionsSelectorConfig | null;
setConfig: (config: Partial<CaptionsSelectorConfig>) => void;
videoChangeListener: () => void;
captionsButtonClickListener: () => void;
},
CaptionsSelectorConfig
>({

View File

@ -1,151 +0,0 @@
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { createRenderer } from '@/utils';
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
import { YoutubePlayer } from '@/types/youtube-player';
export interface LanguageOptions {
displayName: string;
id: string | null;
is_default: boolean;
is_servable: boolean;
is_translateable: boolean;
kind: string;
languageCode: string; // 2 length
languageName: string;
name: string | null;
vss_id: string;
}
export interface CaptionsSelectorConfig {
enabled: boolean;
disableCaptions: boolean;
autoload: boolean;
lastCaptionsCode: string;
}
export default createRenderer<
{
captionsSettingsButton: HTMLElement;
captionTrackList: LanguageOptions[] | null;
api: YoutubePlayer | null;
config: CaptionsSelectorConfig | null;
setConfig: (config: Partial<CaptionsSelectorConfig>) => void;
videoChangeListener: () => void;
captionsButtonClickListener: () => void;
},
CaptionsSelectorConfig
>({
captionsSettingsButton: ElementFromHtml(CaptionsSettingsButtonHTML),
captionTrackList: null,
api: null,
config: null,
setConfig: () => {},
async captionsButtonClickListener() {
if (this.captionTrackList?.length) {
const currentCaptionTrack = this.api!.getOption<LanguageOptions>(
'captions',
'track',
);
let currentIndex = currentCaptionTrack
? this.captionTrackList.indexOf(
this.captionTrackList.find(
(track) =>
track.languageCode === currentCaptionTrack.languageCode,
)!,
)
: null;
const captionLabels = [
...this.captionTrackList.map((track) => track.displayName),
'None',
];
currentIndex = (await window.ipcRenderer.invoke(
'captionsSelector',
captionLabels,
currentIndex,
)) as number;
if (currentIndex === null) {
return;
}
const newCaptions = this.captionTrackList[currentIndex];
this.setConfig({ lastCaptionsCode: newCaptions?.languageCode });
if (newCaptions) {
this.api?.setOption('captions', 'track', {
languageCode: newCaptions.languageCode,
});
} else {
this.api?.setOption('captions', 'track', {});
}
setTimeout(() => this.api?.playVideo());
}
},
videoChangeListener() {
if (this.config?.disableCaptions) {
setTimeout(() => this.api!.unloadModule('captions'), 100);
this.captionsSettingsButton.style.display = 'none';
return;
}
this.api!.loadModule('captions');
setTimeout(() => {
this.captionTrackList =
this.api!.getOption('captions', 'tracklist') ?? [];
if (this.config!.autoload && this.config!.lastCaptionsCode) {
this.api?.setOption('captions', 'track', {
languageCode: this.config!.lastCaptionsCode,
});
}
this.captionsSettingsButton.style.display = this.captionTrackList?.length
? 'inline-block'
: 'none';
}, 250);
},
async start({ getConfig, setConfig }) {
this.config = await getConfig();
this.setConfig = setConfig;
},
stop() {
document
.querySelector('.right-controls-buttons')
?.removeChild(this.captionsSettingsButton);
document
.querySelector<YoutubePlayer & HTMLElement>('#movie_player')
?.unloadModule('captions');
document
.querySelector('video')
?.removeEventListener('ytmd:src-changed', this.videoChangeListener);
this.captionsSettingsButton.removeEventListener(
'click',
this.captionsButtonClickListener,
);
},
onPlayerApiReady(playerApi) {
this.api = playerApi;
document
.querySelector('.right-controls-buttons')
?.append(this.captionsSettingsButton);
this.captionTrackList =
this.api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
document
.querySelector('video')
?.addEventListener('ytmd:src-changed', this.videoChangeListener);
this.captionsSettingsButton.addEventListener(
'click',
this.captionsButtonClickListener,
);
},
onConfigChange(newConfig) {
this.config = newConfig;
},
});

View File

@ -0,0 +1,164 @@
import { render } from 'solid-js/web';
import { createSignal, Show } from 'solid-js';
import { createRenderer } from '@/utils';
import { t } from '@/i18n';
import { CaptionsSettingButton } from './templates/captions-settings-template';
import type { YoutubePlayer } from '@/types/youtube-player';
import type { AppElement } from '@/types/queue';
export interface LanguageOptions {
displayName: string;
id: string | null;
is_default: boolean;
is_servable: boolean;
is_translateable: boolean;
kind: string;
languageCode: string; // 2 length
languageName: string;
name: string | null;
vss_id: string;
}
export interface CaptionsSelectorConfig {
enabled: boolean;
disableCaptions: boolean;
autoload: boolean;
lastCaptionsCode: string;
}
const [hidden, setHidden] = createSignal(false);
export default createRenderer<
{
captionsSettingsButton?: HTMLElement;
captionTrackList: LanguageOptions[] | null;
api: YoutubePlayer | null;
config: CaptionsSelectorConfig | null;
videoChangeListener: () => void;
},
CaptionsSelectorConfig
>({
captionTrackList: null,
api: null,
config: null,
videoChangeListener() {
if (this.config?.disableCaptions) {
setTimeout(() => this.api!.unloadModule('captions'), 100);
setHidden(true);
return;
}
this.api!.loadModule('captions');
setTimeout(() => {
this.captionTrackList =
this.api!.getOption('captions', 'tracklist') ?? [];
if (this.config!.autoload && this.config!.lastCaptionsCode) {
this.api?.setOption('captions', 'track', {
languageCode: this.config!.lastCaptionsCode,
});
}
setHidden(!this.captionTrackList?.length);
}, 250);
},
async start({ getConfig }) {
this.config = await getConfig();
},
stop() {
this.api?.unloadModule('captions');
document
.querySelector('video')
?.removeEventListener('ytmd:src-changed', this.videoChangeListener);
if (this.captionsSettingsButton) {
document
.querySelector('.right-controls-buttons')
?.removeChild(this.captionsSettingsButton);
}
},
onPlayerApiReady(playerApi, { ipc, setConfig }) {
this.api = playerApi;
render(
() => (
<Show when={!hidden()}>
<CaptionsSettingButton
label={t('plugins.captions-selector.templates.title')}
onClick={async () => {
const appApi = document.querySelector<AppElement>('ytmusic-app');
if (this.captionTrackList?.length) {
const currentCaptionTrack =
playerApi.getOption<LanguageOptions>('captions', 'track');
let currentIndex = currentCaptionTrack
? this.captionTrackList.indexOf(
this.captionTrackList.find(
(track) =>
track.languageCode ===
currentCaptionTrack.languageCode,
)!,
)
: null;
const captionLabels = [
...this.captionTrackList.map((track) => track.displayName),
'None',
];
currentIndex = (await ipc.invoke(
'ytmd:captions-selector',
captionLabels,
currentIndex,
)) as number;
if (currentIndex === null) {
return;
}
const newCaptions = this.captionTrackList[currentIndex];
setConfig({ lastCaptionsCode: newCaptions?.languageCode });
if (newCaptions) {
playerApi.setOption('captions', 'track', {
languageCode: newCaptions.languageCode,
});
appApi?.toastService?.show(
t('plugins.captions-selector.toast.caption-changed', {
language: newCaptions.displayName,
}),
);
} else {
playerApi.setOption('captions', 'track', {});
appApi?.toastService?.show(
t('plugins.captions-selector.toast.caption-disabled'),
);
}
setTimeout(() => playerApi.playVideo());
} else {
appApi?.toastService?.show(
t('plugins.captions-selector.toast.no-captions'),
);
}
}}
ref={this.captionsSettingsButton}
/>
</Show>
),
document.querySelector('.right-controls-buttons')!,
);
this.captionTrackList =
this.api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
document
.querySelector('video')
?.addEventListener('ytmd:src-changed', this.videoChangeListener);
},
onConfigChange(newConfig) {
this.config = newConfig;
},
});

View File

@ -1,26 +0,0 @@
<tp-yt-paper-icon-button
aria-disabled="false"
aria-label="Open captions selector"
class="player-captions-button style-scope ytmusic-player"
icon="yt-icons:subtitles"
role="button"
tabindex="0"
title="Open captions selector"
>
<tp-yt-iron-icon class="style-scope tp-yt-paper-icon-button" id="icon">
<svg
class="style-scope yt-icon"
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
class="style-scope tp-yt-iron-icon"
d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-9 6H8v4h3v2H8c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2zm7 0h-3v4h3v2h-3c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2z"
></path>
</g>
</svg>
</tp-yt-iron-icon>
</tp-yt-paper-icon-button>

View File

@ -0,0 +1,42 @@
export interface CaptionsSettingsButtonProps {
label: string;
onClick: (event: MouseEvent) => void;
}
export const CaptionsSettingButton = (props: CaptionsSettingsButtonProps) => (
<yt-icon-button
aria-disabled={false}
aria-label={props.label}
class="player-captions-button style-scope ytmusic-player-bar"
icon={'yt-icons:subtitles'}
role={'button'}
tabindex={0}
title={props.label}
on:click={props.onClick}
>
<span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape">
<div
style={{
width: '100%',
height: '100%',
display: 'block',
fill: 'currentcolor',
}}
>
<svg
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-9 6H8v4h3v2H8c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2zm7 0h-3v4h3v2h-3c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2z"
></path>
</g>
</svg>
</div>
</span>
</yt-icon-button>
);

View File

@ -1,26 +1,24 @@
import downloadHTML from './templates/download.html?raw';
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import defaultConfig from '@/config/defaults';
import { getSongMenu } from '@/providers/dom-elements';
import { getSongInfo } from '@/providers/song-info-front';
import { LoggerPrefix } from '@/utils';
import { t } from '@/i18n';
import { isMusicOrVideoTrack } from '@/plugins/utils/renderer/check';
import { defaultTrustedTypePolicy } from '@/utils/trusted-types';
import { ElementFromHtml } from '../utils/renderer';
import { DownloadButton } from './templates/download';
import type { RendererContext } from '@/types/contexts';
import type { DownloaderPluginConfig } from './index';
let menu: Element | null = null;
let progress: Element | null = null;
const downloadButton = ElementFromHtml(downloadHTML);
let menu: HTMLElement | null = null;
let download: () => void;
let doneFirstLoad = false;
const [downloadButtonText, setDownloadButtonText] = createSignal<string>('');
let buttonContainer: HTMLDivElement | null = null;
const menuObserver = new MutationObserver(() => {
if (!menu) {
@ -30,46 +28,24 @@ const menuObserver = new MutationObserver(() => {
}
}
if (menu.contains(downloadButton)) {
if (
menu.contains(buttonContainer) ||
!isMusicOrVideoTrack() ||
!buttonContainer
) {
return;
}
// check for video (or music)
let menuUrl = document.querySelector<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(downloadButton);
progress = document.querySelector('#ytmcustom-download');
if (!doneFirstLoad) {
setTimeout(() => (doneFirstLoad ||= true), 500);
}
menu.prepend(buttonContainer);
});
export const onRendererLoad = ({
ipc,
}: RendererContext<DownloaderPluginConfig>) => {
window.download = () => {
download = () => {
const songMenu = getSongMenu();
let videoUrl = songMenu
// Selector of first button which is always "Start Radio"
?.querySelector(
'ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint',
)
@ -108,21 +84,30 @@ export const onRendererLoad = ({
};
ipc.on('downloader-feedback', (feedback: string) => {
if (progress) {
const targetHtml = feedback || t('plugins.downloader.templates.button');
(progress.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy
? defaultTrustedTypePolicy.createHTML(targetHtml)
: targetHtml;
} else {
console.warn(
LoggerPrefix,
t('plugins.downloader.renderer.can-not-update-progress'),
);
}
const targetHtml = feedback || t('plugins.downloader.templates.button');
setDownloadButtonText(targetHtml);
});
};
export const onPlayerApiReady = () => {
setDownloadButtonText(t('plugins.downloader.templates.button'));
buttonContainer = document.createElement('div');
buttonContainer.classList.add(
'style-scope',
'menu-item',
'ytmusic-menu-popup-renderer',
);
buttonContainer.setAttribute('aria-disabled', 'false');
buttonContainer.setAttribute('aria-selected', 'false');
buttonContainer.setAttribute('role', 'option');
buttonContainer.setAttribute('tabindex', '-1');
render(
() => <DownloadButton onClick={download} text={downloadButtonText()} />,
buttonContainer,
);
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true,
subtree: true,

View File

@ -1,24 +1,23 @@
<div
aria-disabled="false"
aria-selected="false"
class="style-scope menu-item ytmusic-menu-popup-renderer"
onclick="download()"
role="option"
tabindex="-1"
>
export const DownloadButton = (props: {
onClick: () => void;
text: string;
}) => (
<a
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex="-1"
tabindex={-1}
onClick={props.onClick}
>
<div
class="icon ytmd-menu-item style-scope ytmusic-menu-navigation-item-renderer"
>
<div class="icon ytmd-menu-item style-scope ytmusic-menu-navigation-item-renderer">
<svg
class="style-scope yt-icon"
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
@ -39,7 +38,7 @@
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-download"
>
<ytmd-trans key="plugins.downloader.templates.button"></ytmd-trans>
{props.text}
</div>
</a>
</div>
);

View File

@ -0,0 +1,42 @@
export interface BackButtonProps {
onClick?: (e: MouseEvent) => void;
title: string;
}
export const BackButton = (props: BackButtonProps) => (
<div
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
onClick={props.onClick}
role="tab"
tab-id="FEmusic_back"
>
<div
aria-disabled="false"
class="search-icon style-scope ytmusic-search-box"
role="button"
tabindex={0}
title={props.title}
>
<div
class="tab-icon style-scope paper-icon-button navigation-icon"
id="icon"
>
<svg
class="style-scope iron-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 492 492"
>
<g class="style-scope iron-icon">
<path d="M109.3 265.2l218.9 218.9c5.1 5.1 11.8 7.9 19 7.9s14-2.8 19-7.9l16.1-16.1c10.5-10.5 10.5-27.6 0-38.1L198.6 246.1 382.7 62c5.1-5.1 7.9-11.8 7.9-19 0-7.2-2.8-14-7.9-19L366.5 7.9c-5.1-5.1-11.8-7.9-19-7.9-7.2 0-14 2.8-19 7.9L109.3 227c-5.1 5.1-7.9 11.9-7.8 19.1 0 7.2 2.8 14 7.8 19.1z"></path>
</g>
</svg>
</div>
</div>
</div>
);

View File

@ -0,0 +1,46 @@
export interface ForwardButtonProps {
onClick?: (e: MouseEvent) => void;
title: string;
}
export const ForwardButton = (props: ForwardButtonProps) => (
<div
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
onClick={props.onClick}
role="tab"
tab-id="FEmusic_next"
>
<div
aria-disabled="false"
class="search-icon style-scope ytmusic-search-box"
role="button"
tabindex={0}
title={props.title}
>
<div
class="tab-icon style-scope paper-icon-button navigation-icon"
id="icon"
>
<svg
class="style-scope iron-icon"
preserveAspectRatio="xMidYMid meet"
style={{
'pointer-events': 'none',
'display': 'block',
'width': '100%',
'height': '100%',
}}
viewBox="0 0 492 492"
>
<g class="style-scope iron-icon">
<path
d="M382.7,226.8L163.7,7.9c-5.1-5.1-11.8-7.9-19-7.9s-14,2.8-19,7.9L109.5,24c-10.5,10.5-10.5,27.6,0,38.1
l183.9,183.9L109.3,430c-5.1,5.1-7.9,11.8-7.9,19c0,7.2,2.8,14,7.9,19l16.1,16.1c5.1,5.1,11.8,7.9,19,7.9s14-2.8,19-7.9L382.7,265
c5.1-5.1,7.9-11.9,7.8-19.1C390.5,238.7,387.8,231.9,382.7,226.8z"
></path>
</g>
</svg>
</div>
</div>
</div>
);

View File

@ -1,30 +0,0 @@
import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { t } from '@/i18n';
import forwardHTML from './templates/forward.html?raw';
import backHTML from './templates/back.html?raw';
export default createPlugin({
name: () => t('plugins.navigation.name'),
description: () => t('plugins.navigation.description'),
restartNeeded: false,
config: {
enabled: true,
},
stylesheets: [style],
renderer: {
start() {
const forwardButton = ElementFromHtml(forwardHTML);
const backButton = ElementFromHtml(backHTML);
const menu = document.querySelector('#right-content');
menu?.prepend(backButton, forwardButton);
},
stop() {
document.querySelector('[tab-id=FEmusic_back]')?.remove();
document.querySelector('[tab-id=FEmusic_next]')?.remove();
},
},
});

View File

@ -0,0 +1,48 @@
import { render } from 'solid-js/web';
import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { t } from '@/i18n';
import { ForwardButton } from './components/forward-button';
import { BackButton } from './components/back-button';
export default createPlugin({
name: () => t('plugins.navigation.name'),
description: () => t('plugins.navigation.description'),
restartNeeded: false,
config: {
enabled: true,
},
stylesheets: [style],
renderer: {
buttonContainer: document.createElement('div'),
start() {
if (!this.buttonContainer) {
this.buttonContainer = document.createElement('div');
}
render(
() => (
<>
<BackButton
onClick={() => history.back()}
title={t('plugins.navigation.templates.back.title')}
/>
<ForwardButton
onClick={() => history.forward()}
title={t('plugins.navigation.templates.forward.title')}
/>
</>
),
this.buttonContainer,
);
const menu = document.querySelector('#right-content');
menu?.prepend(this.buttonContainer);
},
stop() {
this.buttonContainer.remove();
},
},
});

View File

@ -1,33 +0,0 @@
<div
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
onclick="history.back()"
role="tab"
tab-id="FEmusic_back"
>
<div
aria-disabled="false"
class="search-icon style-scope ytmusic-search-box"
role="button"
tabindex="0"
title="Go to previous page"
>
<div
class="tab-icon style-scope paper-icon-button navigation-icon"
id="icon"
>
<svg
class="style-scope iron-icon"
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 492 492"
>
<g class="style-scope iron-icon">
<path
d="M109.3 265.2l218.9 218.9c5.1 5.1 11.8 7.9 19 7.9s14-2.8 19-7.9l16.1-16.1c10.5-10.5 10.5-27.6 0-38.1L198.6 246.1 382.7 62c5.1-5.1 7.9-11.8 7.9-19 0-7.2-2.8-14-7.9-19L366.5 7.9c-5.1-5.1-11.8-7.9-19-7.9-7.2 0-14 2.8-19 7.9L109.3 227c-5.1 5.1-7.9 11.9-7.8 19.1 0 7.2 2.8 14 7.8 19.1z"
></path>
</g>
</svg>
</div>
</div>
</div>

View File

@ -1,35 +0,0 @@
<div
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
onclick="history.forward()"
role="tab"
tab-id="FEmusic_next"
>
<div
aria-disabled="false"
class="search-icon style-scope ytmusic-search-box"
role="button"
tabindex="0"
title="Go to next page"
>
<div
class="tab-icon style-scope paper-icon-button navigation-icon"
id="icon"
>
<svg
class="style-scope iron-icon"
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 492 492"
>
<g class="style-scope iron-icon">
<path
d="M382.7,226.8L163.7,7.9c-5.1-5.1-11.8-7.9-19-7.9s-14,2.8-19,7.9L109.5,24c-10.5,10.5-10.5,27.6,0,38.1
l183.9,183.9L109.3,430c-5.1,5.1-7.9,11.8-7.9,19c0,7.2,2.8,14,7.9,19l16.1,16.1c5.1,5.1,11.8,7.9,19,7.9s14-2.8,19-7.9L382.7,265
c5.1-5.1,7.9-11.9,7.8-19.1C390.5,238.7,387.8,231.9,382.7,226.8z"
></path>
</g>
</svg>
</div>
</div>
</div>

View File

@ -309,9 +309,10 @@ export default (
savedNotification?.close();
});
changeProtocolHandler((cmd, args) => {
changeProtocolHandler((cmd, ...args) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd as keyof typeof songControls](args as never);
// @ts-expect-error: cmd is a key of songControls
songControls[cmd as keyof typeof songControls](...args);
if (
config().refreshOnPlayPause &&
(cmd === 'pause' || (cmd === 'play' && !config().unpauseNotification))

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>

View File

@ -0,0 +1,92 @@
export interface PlaybackSpeedSliderProps {
speed: number;
title: string;
onImmediateValueChanged?: (value: CustomEvent<{ value: number }>) => void;
onWheel?: (event: WheelEvent) => void;
}
export const PlaybackSpeedSlider = (props: PlaybackSpeedSliderProps) => (
<div
aria-disabled="false"
aria-selected="false"
class="style-scope menu-item ytmusic-menu-popup-renderer"
role="option"
tabindex="-1"
>
<div
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex="-1"
>
<tp-yt-paper-slider
aria-disabled="false"
aria-label={props.title}
aria-valuemax="2"
aria-valuemin="0"
aria-valuenow={props.speed}
class="volume-slider style-scope ytmusic-player-bar on-hover"
dir="ltr"
on:immediate-value-changed={props.onImmediateValueChanged}
onWheel={props.onWheel}
max="2"
min="0"
role="slider"
step="0.125"
style="display: inherit !important"
tabindex="0"
title={props.title}
value={props.speed}
>
<div class="style-scope tp-yt-paper-slider" id="sliderContainer">
<div class="bar-container style-scope tp-yt-paper-slider">
<tp-yt-paper-progress
aria-disabled="false"
aria-hidden="true"
aria-valuemax="2"
aria-valuemin="0"
aria-valuenow="1"
class="style-scope tp-yt-paper-slider"
id="sliderBar"
role="progressbar"
style="touch-action: none"
value="1"
>
<div
class="style-scope tp-yt-paper-progress"
id="progressContainer"
>
<div
class="style-scope tp-yt-paper-progress"
hidden={true}
id="secondaryProgress"
style="transform: scaleX(0)"
/>
<div
class="style-scope tp-yt-paper-progress"
id="primaryProgress"
style="transform: scaleX(0.5)"
/>
</div>
</tp-yt-paper-progress>
</div>
<div
class="slider-knob style-scope tp-yt-paper-slider"
id="sliderKnob"
style="left: 50%; touch-action: none"
>
<input
class="slider-knob-inner style-scope tp-yt-paper-slider"
value={1}
/>
</div>
</div>
</tp-yt-paper-slider>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-playback-speed"
>
{props.title} ({props.speed})
</div>
</div>
</div>
);

View File

@ -1,141 +0,0 @@
import sliderHTML from './templates/slider.html?raw';
import { getSongMenu } from '@/providers/dom-elements';
import { singleton } from '@/providers/decorators';
import { defaultTrustedTypePolicy } from '@/utils/trusted-types';
import { ElementFromHtml } from '../utils/renderer';
const slider = ElementFromHtml(sliderHTML);
const roundToTwo = (n: number) => Math.round(n * 1e2) / 1e2;
const MIN_PLAYBACK_SPEED = 0.07;
const MAX_PLAYBACK_SPEED = 16;
let playbackSpeed = 1;
const updatePlayBackSpeed = () => {
const videoElement = document.querySelector<HTMLVideoElement>('video');
if (videoElement) {
videoElement.playbackRate = playbackSpeed;
}
const playbackSpeedElement = document.querySelector('#playback-speed-value');
if (playbackSpeedElement) {
const targetHtml = String(playbackSpeed);
(playbackSpeedElement.innerHTML as string | TrustedHTML) =
defaultTrustedTypePolicy
? defaultTrustedTypePolicy.createHTML(targetHtml)
: targetHtml;
}
};
let menu: Element | null = null;
const immediateValueChangedListener = (e: Event) => {
playbackSpeed =
(e as CustomEvent<{ value: number }>).detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
updatePlayBackSpeed();
};
const setupSliderListener = singleton(() => {
document
.querySelector('#playback-speed-slider')
?.addEventListener(
'immediate-value-changed',
immediateValueChangedListener,
);
});
const observePopupContainer = () => {
const observer = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
}
if (menu && !menu.contains(slider)) {
menu.prepend(slider);
setupSliderListener();
}
});
const popupContainer = document.querySelector('ytmusic-popup-container');
if (popupContainer) {
observer.observe(popupContainer, {
childList: true,
subtree: true,
});
}
};
const observeVideo = () => {
const video = document.querySelector<HTMLVideoElement>('video');
if (video) {
video.addEventListener('ratechange', forcePlaybackRate);
video.addEventListener('ytmd:src-changed', forcePlaybackRate);
}
};
const wheelEventListener = (e: WheelEvent) => {
e.preventDefault();
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
// E.deltaY < 0 means wheel-up
playbackSpeed = roundToTwo(
e.deltaY < 0
? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED)
: Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED),
);
updatePlayBackSpeed();
// Update slider position
const playbackSpeedSilder = document.querySelector<
HTMLElement & { value: number }
>('#playback-speed-slider');
if (playbackSpeedSilder) {
playbackSpeedSilder.value = playbackSpeed;
}
};
const setupWheelListener = () => {
slider.addEventListener('wheel', wheelEventListener);
};
function forcePlaybackRate(e: Event) {
if (e.target instanceof HTMLVideoElement) {
const videoElement = e.target;
if (videoElement.playbackRate !== playbackSpeed) {
videoElement.playbackRate = playbackSpeed;
}
}
}
export const onPlayerApiReady = () => {
observePopupContainer();
observeVideo();
setupWheelListener();
};
export const onUnload = () => {
const video = document.querySelector<HTMLVideoElement>('video');
if (video) {
video.removeEventListener('ratechange', forcePlaybackRate);
video.removeEventListener('ytmd:src-changed', forcePlaybackRate);
}
slider.removeEventListener('wheel', wheelEventListener);
getSongMenu()?.removeChild(slider);
document
.querySelector('#playback-speed-slider')
?.removeEventListener(
'immediate-value-changed',
immediateValueChangedListener,
);
};

View File

@ -0,0 +1,119 @@
import { render } from 'solid-js/web';
import { createSignal } from 'solid-js';
import { getSongMenu } from '@/providers/dom-elements';
import { PlaybackSpeedSlider } from './components/slider';
import { t } from '@/i18n';
import { isMusicOrVideoTrack } from '@/plugins/utils/renderer/check';
const MIN_PLAYBACK_SPEED = 0.07;
const MAX_PLAYBACK_SPEED = 16;
const forcePlaybackRate = (e: Event) => {
if (e.target instanceof HTMLVideoElement) {
const videoElement = e.target;
if (videoElement.playbackRate !== speed()) {
videoElement.playbackRate = speed();
}
}
};
const roundToTwo = (n: number) => Math.round(n * 1e2) / 1e2;
const [speed, setSpeed] = createSignal(1);
const sliderContainer = document.createElement('div');
export const onPlayerApiReady = () => {
const observePopupContainer = () => {
const updatePlayBackSpeed = () => {
const videoElement = document.querySelector<HTMLVideoElement>('video');
if (videoElement) {
videoElement.playbackRate = speed();
}
setSpeed(speed());
};
render(
() => (
<PlaybackSpeedSlider
speed={speed()}
title={t('plugins.playback-speed.templates.button')}
onImmediateValueChanged={(e) => {
let targetSpeed = Number(e.detail.value ?? MIN_PLAYBACK_SPEED);
if (isNaN(targetSpeed)) {
targetSpeed = 1;
}
targetSpeed = Math.min(
Math.max(MIN_PLAYBACK_SPEED, targetSpeed),
MAX_PLAYBACK_SPEED,
);
setSpeed(targetSpeed);
updatePlayBackSpeed();
}}
onWheel={(e) => {
e.preventDefault();
if (isNaN(speed())) {
setSpeed(1);
}
// E.deltaY < 0 means wheel-up
setSpeed((prev) =>
roundToTwo(
e.deltaY < 0
? Math.min(prev + 0.01, MAX_PLAYBACK_SPEED)
: Math.max(prev - 0.01, MIN_PLAYBACK_SPEED),
),
);
updatePlayBackSpeed();
}}
/>
),
sliderContainer,
);
const observer = new MutationObserver(() => {
const menu = getSongMenu();
if (menu && !menu.contains(sliderContainer) && isMusicOrVideoTrack()) {
menu.prepend(sliderContainer);
}
});
const popupContainer = document.querySelector('ytmusic-popup-container');
if (popupContainer) {
observer.observe(popupContainer, {
childList: true,
subtree: true,
});
}
};
const observeVideo = () => {
const video = document.querySelector<HTMLVideoElement>('video');
if (video) {
video.addEventListener('ratechange', forcePlaybackRate);
video.addEventListener('ytmd:src-changed', forcePlaybackRate);
}
};
observePopupContainer();
observeVideo();
};
export const onUnload = () => {
const video = document.querySelector<HTMLVideoElement>('video');
if (video) {
video.removeEventListener('ratechange', forcePlaybackRate);
video.removeEventListener('ytmd:src-changed', forcePlaybackRate);
}
getSongMenu()?.removeChild(sliderContainer);
};

View File

@ -1,90 +0,0 @@
<div
aria-disabled="false"
aria-selected="false"
class="style-scope menu-item ytmusic-menu-popup-renderer"
role="option"
tabindex="-1"
>
<div
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex="-1"
>
<tp-yt-paper-slider
aria-disabled="false"
aria-label="Playback speed"
aria-valuemax="2"
aria-valuemin="0"
aria-valuenow="1"
class="volume-slider style-scope ytmusic-player-bar on-hover"
dir="ltr"
id="playback-speed-slider"
max="2"
min="0"
role="slider"
step="0.125"
style="display: inherit !important"
tabindex="0"
title="Playback speed"
value="1"
><!--css-build:shady-->
<div class="style-scope tp-yt-paper-slider" id="sliderContainer">
<div class="bar-container style-scope tp-yt-paper-slider">
<tp-yt-paper-progress
aria-disabled="false"
aria-hidden="true"
aria-valuemax="2"
aria-valuemin="0"
aria-valuenow="1"
class="style-scope tp-yt-paper-slider"
id="sliderBar"
role="progressbar"
style="touch-action: none"
value="1"
><!--css-build:shady-->
<div
class="style-scope tp-yt-paper-progress"
id="progressContainer"
>
<div
class="style-scope tp-yt-paper-progress"
hidden="true"
id="secondaryProgress"
style="transform: scaleX(0)"
></div>
<div
class="style-scope tp-yt-paper-progress"
id="primaryProgress"
style="transform: scaleX(0.5)"
></div>
</div>
</tp-yt-paper-progress>
</div>
<dom-if class="style-scope tp-yt-paper-slider">
<template is="dom-if"></template>
</dom-if>
<div
class="slider-knob style-scope tp-yt-paper-slider"
id="sliderKnob"
style="left: 50%; touch-action: none"
>
<div
class="slider-knob-inner style-scope tp-yt-paper-slider"
value="1"
></div>
</div>
</div>
<dom-if class="style-scope tp-yt-paper-slider">
<template is="dom-if"></template>
</dom-if>
</tp-yt-paper-slider>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-playback-speed"
>
<ytmd-trans key="plugins.playback-speed.templates.button"></ytmd-trans>
(<span id="playback-speed-value">1</span>)
</div>
</div>
</div>

View File

@ -1,11 +1,12 @@
import { dialog } from 'electron';
import QualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
import { render } from 'solid-js/web';
import { createPlugin } from '@/utils';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { t } from '@/i18n';
import { QualitySettingButton } from './templates/quality-setting-button';
import type { YoutubePlayer } from '@/types/youtube-player';
export default createPlugin({
@ -18,7 +19,7 @@ export default createPlugin({
backend({ ipc, window }) {
ipc.handle(
'qualityChanger',
'ytmd:quality-changer',
async (qualityLabels: string[], currentIndex: number) =>
await dialog.showMessageBox(window, {
type: 'question',
@ -42,40 +43,48 @@ export default createPlugin({
},
renderer: {
qualitySettingsButton: ElementFromHtml(QualitySettingsTemplate),
qualitySettingsButtonContainer: document.createElement('div'),
onPlayerApiReady(api: YoutubePlayer, context) {
const getPlayer = () =>
document.querySelector<HTMLVideoElement>('#player');
const chooseQuality = () => {
setTimeout(() => getPlayer()?.click());
const chooseQuality = async (e: MouseEvent) => {
e.stopPropagation();
const qualityLevels = api.getAvailableQualityLevels();
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
(
context.ipc.invoke(
'qualityChanger',
api.getAvailableQualityLabels(),
currentIndex,
) as Promise<{ response: number }>
).then((promise) => {
if (promise.response === -1) {
return;
}
const quality = (await context.ipc.invoke(
'ytmd:quality-changer',
api.getAvailableQualityLabels(),
currentIndex,
)) as {
response: number;
};
const newQuality = qualityLevels[promise.response];
api.setPlaybackQualityRange(newQuality);
api.setPlaybackQuality(newQuality);
});
if (quality.response === -1) {
return;
}
const newQuality = qualityLevels[quality.response];
api.setPlaybackQualityRange(newQuality);
api.setPlaybackQuality(newQuality);
};
render(
() => (
<QualitySettingButton
label={t(
'plugins.quality-changer.renderer.quality-settings-button.label',
)}
onClick={chooseQuality}
/>
),
this.qualitySettingsButtonContainer,
);
const setup = () => {
document
.querySelector('.top-row-buttons.ytmusic-player')
?.prepend(this.qualitySettingsButton);
this.qualitySettingsButton.addEventListener('click', chooseQuality);
?.prepend(this.qualitySettingsButtonContainer);
};
setup();
@ -83,7 +92,7 @@ export default createPlugin({
stop() {
document
.querySelector('.top-row-buttons.ytmusic-player')
?.removeChild(this.qualitySettingsButton);
?.removeChild(this.qualitySettingsButtonContainer);
},
},
});

View File

@ -0,0 +1,42 @@
export interface QualitySettingButtonProps {
label: string;
onClick: (event: MouseEvent) => void;
}
export const QualitySettingButton = (props: QualitySettingButtonProps) => (
<yt-icon-button
aria-disabled={false}
aria-label={props.label}
class="player-quality-button style-scope ytmusic-player"
icon={'yt-icons:settings'}
role={'button'}
tabindex={0}
title={props.label}
on:click={props.onClick}
>
<span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape">
<div
style={{
width: '100%',
height: '100%',
display: 'block',
fill: 'currentcolor',
}}
>
<svg
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.1-1.65c.2-.15.25-.42.13-.64l-2-3.46c-.12-.22-.4-.3-.6-.22l-2.5 1c-.52-.4-1.08-.73-1.7-.98l-.37-2.65c-.06-.24-.27-.42-.5-.42h-4c-.27 0-.48.18-.5.42l-.4 2.65c-.6.25-1.17.6-1.7.98l-2.48-1c-.23-.1-.5 0-.6.22l-2 3.46c-.14.22-.08.5.1.64l2.12 1.65c-.04.32-.07.65-.07.98s.02.66.06.98l-2.1 1.65c-.2.15-.25.42-.13.64l2 3.46c.12.22.4.3.6.22l2.5-1c.52.4 1.08.73 1.7.98l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.6-.25 1.17-.6 1.7-.98l2.48 1c.23.1.5 0 .6-.22l2-3.46c.13-.22.08-.5-.1-.64l-2.12-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"
></path>
</g>
</svg>
</div>
</span>
</yt-icon-button>
);

View File

@ -1,26 +0,0 @@
<tp-yt-paper-icon-button
aria-disabled="false"
aria-label="Open player quality changer"
class="player-quality-button style-scope ytmusic-player"
icon="yt-icons:settings"
role="button"
tabindex="0"
title="Open player quality changer"
>
<tp-yt-iron-icon class="style-scope tp-yt-paper-icon-button" id="icon">
<svg
class="style-scope yt-icon"
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="pointer-events: none; display: block; width: 100%; height: 100%"
viewBox="0 0 24 24"
>
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.1-1.65c.2-.15.25-.42.13-.64l-2-3.46c-.12-.22-.4-.3-.6-.22l-2.5 1c-.52-.4-1.08-.73-1.7-.98l-.37-2.65c-.06-.24-.27-.42-.5-.42h-4c-.27 0-.48.18-.5.42l-.4 2.65c-.6.25-1.17.6-1.7.98l-2.48-1c-.23-.1-.5 0-.6.22l-2 3.46c-.14.22-.08.5.1.64l2.12 1.65c-.04.32-.07.65-.07.98s.02.66.06.98l-2.1 1.65c-.2.15-.25.42-.13.64l2 3.46c.12.22.4.3.6.22l2.5-1c.52.4 1.08.73 1.7.98l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.6-.25 1.17-.6 1.7-.98l2.48 1c.23.1.5 0 .6-.22l2-3.46c.13-.22.08-.5-.1-.64l-2.12-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"
></path>
</g>
</svg>
</tp-yt-iron-icon>
</tp-yt-paper-icon-button>

View File

@ -36,7 +36,7 @@ export const onMainLoad = async ({
const config = await getConfig();
const songControls = getSongControls(window);
const { playPause, next, previous, search } = songControls;
const { playPause, next, previous } = songControls;
if (config.overrideMediaKeys) {
_registerGlobalShortcut(window.webContents, 'MediaPlayPause', playPause);
@ -44,9 +44,6 @@ export const onMainLoad = async ({
_registerGlobalShortcut(window.webContents, 'MediaPreviousTrack', previous);
}
_registerLocalShortcut(window, 'CommandOrControl+F', search);
_registerLocalShortcut(window, 'CommandOrControl+L', search);
if (is.linux()) {
registerMPRIS(window);
}

View File

@ -44,7 +44,6 @@ const providerBias = (p: ProviderName) =>
(lyricsStore.lyrics[p].data?.lines?.length && p === 'YTMusic' ? 1 : 0) +
(lyricsStore.lyrics[p].data?.lyrics ? 1 : -1);
// prettier-ignore
const pickBestProvider = () => {
const providers = Array.from(providerNames);
@ -56,15 +55,17 @@ const pickBestProvider = () => {
const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] =
createSignal(false);
// prettier-ignore
export const LyricsPicker = (props: { setStickRef: Setter<HTMLElement | null> }) => {
export const LyricsPicker = (props: {
setStickRef: Setter<HTMLElement | null>;
}) => {
createEffect(() => {
// fallback to the next source, if the current one has an error
if (!hasManuallySwitchedProvider()
) {
if (!hasManuallySwitchedProvider()) {
const bestProvider = pickBestProvider();
const allProvidersFailed = providerNames.every((p) => shouldSwitchProvider(lyricsStore.lyrics[p]));
const allProvidersFailed = providerNames.every((p) =>
shouldSwitchProvider(lyricsStore.lyrics[p]),
);
if (allProvidersFailed) return;
if (providerBias(lyricsStore.provider) < providerBias(bestProvider)) {
@ -80,7 +81,9 @@ export const LyricsPicker = (props: { setStickRef: Setter<HTMLElement | null> })
};
_ytAPI?.addEventListener('videodatachange', videoDataChangeHandler);
onCleanup(() => _ytAPI?.removeEventListener('videodatachange', videoDataChangeHandler));
onCleanup(() =>
_ytAPI?.removeEventListener('videodatachange', videoDataChangeHandler),
);
});
const next = () => {
@ -95,7 +98,9 @@ export const LyricsPicker = (props: { setStickRef: Setter<HTMLElement | null> })
setHasManuallySwitchedProvider(true);
setLyricsStore('provider', (prevProvider) => {
const idx = providerNames.indexOf(prevProvider);
return providerNames[(idx + providerNames.length - 1) % providerNames.length];
return providerNames[
(idx + providerNames.length - 1) % providerNames.length
];
});
};
@ -109,7 +114,40 @@ export const LyricsPicker = (props: { setStickRef: Setter<HTMLElement | null> })
return (
<div class="lyrics-picker" ref={props.setStickRef}>
<div class="lyrics-picker-left">
<tp-yt-paper-icon-button icon={chevronLeft} onClick={previous} />
<yt-icon-button
class="style-scope ytmusic-player-bar"
icon={chevronLeft}
onClick={previous}
role={'button'}
>
<span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape">
<div
style={{
'width': '100%',
'height': '100%',
'display': 'flex',
'align-items': 'center',
'fill': 'currentcolor',
}}
>
<svg
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
viewBox="0 -960 960 960"
height={'40px'}
width={'40px'}
fill="#FFFFFF"
>
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M560.67-240 320-480.67l240.67-240.66L608-674 414.67-480.67 608-287.33 560.67-240Z"
/>
</g>
</svg>
</div>
</span>
</yt-icon-button>
</div>
<div class="lyrics-picker-content">
@ -138,7 +176,7 @@ export const LyricsPicker = (props: { setStickRef: Setter<HTMLElement | null> })
/>
</Match>
<Match when={currentLyrics().state === 'error'}>
<tp-yt-paper-icon-button
<yt-icon-button
icon={errorIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
@ -151,18 +189,20 @@ export const LyricsPicker = (props: { setStickRef: Setter<HTMLElement | null> })
currentLyrics().data?.lyrics)
}
>
<tp-yt-paper-icon-button
<yt-icon-button
icon={successIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
/>
</Match>
<Match when={
currentLyrics().state === 'done'
&& !currentLyrics().data?.lines
&& !currentLyrics().data?.lyrics
}>
<tp-yt-paper-icon-button
<Match
when={
currentLyrics().state === 'done' &&
!currentLyrics().data?.lines &&
!currentLyrics().data?.lyrics
}
>
<yt-icon-button
icon={notFoundIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
@ -194,7 +234,40 @@ export const LyricsPicker = (props: { setStickRef: Setter<HTMLElement | null> })
</div>
<div class="lyrics-picker-right">
<tp-yt-paper-icon-button icon={chevronRight} onClick={next} />
<yt-icon-button
class="style-scope ytmusic-player-bar"
icon={chevronRight}
onClick={next}
role={'button'}
>
<span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape">
<div
style={{
'width': '100%',
'height': '100%',
'display': 'flex',
'align-items': 'center',
'fill': 'currentcolor',
}}
>
<svg
class="style-scope yt-icon"
preserveAspectRatio="xMidYMid meet"
viewBox="0 -960 960 960"
height={'40px'}
width={'40px'}
fill="#FFFFFF"
>
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M521.33-480.67 328-674l47.33-47.33L616-480.67 375.33-240 328-287.33l193.33-193.34Z"
/>
</g>
</svg>
</div>
</span>
</yt-icon-button>
</div>
</div>
);

View File

@ -0,0 +1,20 @@
export const isMusicOrVideoTrack = () => {
let menuUrl = document.querySelector<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;
}
}
}
return !!menuUrl;
};

View File

@ -1,4 +1,4 @@
import { defaultTrustedTypePolicy } from '@/utils/trusted-types';
const domParser = new DOMParser();
/**
* Creates a DOM element from an HTML string
@ -6,13 +6,8 @@ import { defaultTrustedTypePolicy } from '@/utils/trusted-types';
* @returns The DOM element
*/
export const ElementFromHtml = (html: string): HTMLElement => {
const template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
(template.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy
? defaultTrustedTypePolicy.createHTML(html)
: html;
return template.content.firstElementChild as HTMLElement;
return (domParser.parseFromString(html, 'text/html') as HTMLDocument).body
.firstElementChild as HTMLElement;
};
/**

View File

@ -25,7 +25,7 @@
}
.video-toggle-custom-mode .video-switch-button:before {
content: 'Video';
content: attr(data-video-button-text);
position: absolute;
top: 0;
bottom: 0;

View File

@ -1,14 +1,18 @@
import buttonTemplate from './templates/button_template.html?raw';
import { render } from 'solid-js/web';
import { createSignal, Show } from 'solid-js';
import forceHideStyle from './force-hide.css?inline';
import buttonSwitcherStyle from './button-switcher.css?inline';
import { createPlugin } from '@/utils';
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '@/plugins/precise-volume/renderer';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { ThumbnailElement } from '@/types/get-player-response';
import { t } from '@/i18n';
import { MenuTemplate } from '@/menu';
import { VideoSwitchButton } from './templates/video-switch-button';
export type VideoTogglePluginConfig = {
enabled: boolean;
hideVideo: boolean;
@ -150,6 +154,8 @@ export default createPlugin({
}
},
async onPlayerApiReady(api, { getConfig }) {
const [showButton, setShowButton] = createSignal(true);
const config = await getConfig();
this.config = config;
@ -164,7 +170,25 @@ export default createPlugin({
>('ytmusic-player');
const video = document.querySelector<HTMLVideoElement>('video');
const switchButtonDiv = ElementFromHtml(buttonTemplate);
const switchButtonContainer = document.createElement('div');
switchButtonContainer.style.display = 'flex';
render(
() => (
<Show when={showButton()}>
<VideoSwitchButton
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const target = e.target as HTMLInputElement;
setVideoState(target.checked);
}}
songButtonText={t('plugins.video-toggle.templates.button-song')}
videoButtonText={t('plugins.video-toggle.templates.button-video',)}
/>
</Show>
),
switchButtonContainer,
);
const forceThumbnail = (img: HTMLImageElement) => {
const thumbnails: ThumbnailElement[] =
@ -184,7 +208,7 @@ export default createPlugin({
const checkbox = document.querySelector<HTMLInputElement>(
'.video-switch-button-checkbox',
); // custom mode
if (checkbox) checkbox.checked = !this.config.hideVideo;
if (checkbox) checkbox.checked = !this.config?.hideVideo;
if (player) {
player.style.margin = showVideo ? '' : 'auto 0px';
@ -217,17 +241,18 @@ export default createPlugin({
// Video doesn't exist -> switch to song mode
setVideoState(false);
// Hide toggle button
switchButtonDiv.style.display = 'none';
setShowButton(false);
} else {
const songImage =
document.querySelector<HTMLImageElement>('#song-image img');
const songImage = document.querySelector<HTMLImageElement>(
'#song-image #img.style-scope.yt-img-shadow',
);
if (!songImage) {
return;
}
// Switch to high-res thumbnail
forceThumbnail(songImage);
// Show toggle button
switchButtonDiv.style.display = 'initial';
setShowButton(true);
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
if (
!this.config?.hideVideo &&
@ -282,7 +307,7 @@ export default createPlugin({
}
});
playbackModeObserver.observe(
document.querySelector('#song-image img')!,
document.querySelector('#song-image #img.style-scope.yt-img-shadow')!,
{ attributeFilter: ['src'] },
);
};
@ -290,7 +315,7 @@ export default createPlugin({
if (config.mode !== 'native' && config.mode != 'disabled') {
document
.querySelector<HTMLVideoElement>('#player')
?.prepend(switchButtonDiv);
?.prepend(switchButtonContainer);
setVideoState(!config.hideVideo);
forcePlaybackMode();
@ -299,36 +324,25 @@ export default createPlugin({
video.style.height = 'auto';
}
//Prevents bubbling to the player which causes it to stop or resume
switchButtonDiv.addEventListener('click', (e) => {
e.stopPropagation();
});
// Button checked = show video
switchButtonDiv.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
setVideoState(target.checked);
});
video?.addEventListener('ytmd:src-changed', videoStarted);
observeThumbnail();
videoStarted();
switch (config.align) {
case 'right': {
switchButtonDiv.style.left = 'calc(100% - 240px)';
switchButtonContainer.style.justifyContent = 'flex-end';
return;
}
case 'middle': {
switchButtonDiv.style.left = 'calc(50% - 120px)';
switchButtonContainer.style.justifyContent = 'center';
return;
}
default:
case 'left': {
switchButtonDiv.style.left = '0px';
switchButtonContainer.style.justifyContent = 'flex-start';
}
}
}

View File

@ -1,8 +0,0 @@
<div class="video-switch-button">
<input checked="true" class="video-switch-button-checkbox" type="checkbox" />
<label class="video-switch-button-label" for="">
<span class="video-switch-button-label-span">
<ytmd-trans key="plugins.video-toggle.templates.button"></ytmd-trans>
</span>
</label>
</div>

View File

@ -0,0 +1,25 @@
export interface VideoSwitchButtonProps {
onClick?: (event: MouseEvent) => void;
onChange?: (event: Event) => void;
songButtonText: string;
videoButtonText: string;
}
export const VideoSwitchButton = (props: VideoSwitchButtonProps) => (
<div
class="video-switch-button"
data-video-button-text={props.videoButtonText}
on:click={props.onClick}
onChange={props.onChange}
>
<input
checked={true}
id="video-toggle-video-switch-button-checkbox"
class="video-switch-button-checkbox"
type="checkbox"
/>
<label class="video-switch-button-label" for="video-toggle-video-switch-button-checkbox">
<span class="video-switch-button-label-span">{props.songButtonText}</span>
</label>
</div>
);

View File

@ -1,4 +1,6 @@
export const getSongMenu = () =>
document.querySelector('ytmusic-menu-popup-renderer tp-yt-paper-listbox');
document.querySelector<HTMLElement>(
'ytmusic-menu-popup-renderer tp-yt-paper-listbox',
);
export default { getSongMenu };

View File

@ -6,9 +6,7 @@ import getSongControls from './song-controls';
export const APP_PROTOCOL = 'youtubemusic';
let protocolHandler:
| ((cmd: string, args: string[] | undefined) => void)
| undefined;
let protocolHandler: ((cmd: string, ...args: string[]) => void) | undefined;
export function setupProtocolHandler(win: BrowserWindow) {
if (process.defaultApp && process.argv.length >= 2) {
@ -21,22 +19,20 @@ export function setupProtocolHandler(win: BrowserWindow) {
const songControls = getSongControls(win);
protocolHandler = ((
cmd: keyof typeof songControls,
args: string[] | undefined = undefined,
) => {
protocolHandler = ((cmd: keyof typeof songControls, ...args) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd](args as never);
// @ts-expect-error: cmd is a key of songControls
songControls[cmd](...args);
}
}) as (cmd: string) => void;
}) as (cmd: string, ...args: string[]) => void;
}
export function handleProtocol(cmd: string, args: string[] | undefined) {
protocolHandler?.(cmd, args);
export function handleProtocol(cmd: string, ...args: string[]) {
protocolHandler?.(cmd, ...args);
}
export function changeProtocolHandler(
f: (cmd: string, args: string[] | undefined) => void,
f: (cmd: string, ...args: string[]) => void,
) {
protocolHandler = f;
}

View File

@ -0,0 +1,6 @@
import { customElement, type ComponentType } from 'solid-element';
export const anonymousCustomElement = <T extends object>(
ComponentType: ComponentType<T>,
): CustomElementConstructor =>
customElement(`ytmd-${crypto.randomUUID()}`, ComponentType);

View File

@ -34,14 +34,35 @@ declare module 'solid-js' {
icon: Icons;
}
interface YtmdTransProps {
key?: string;
}
interface IntrinsicElements {
center: ComponentProps<'div'>;
'ytmd-trans': ComponentProps<'span'> & YtmdTransProps;
'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps;
'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps;
'yt-touch-feedback-shape': ComponentProps<'div'>;
'tp-yt-paper-spinner-lite': ComponentProps<'div'> &
YpYtPaperSpinnerLiteProps;
'tp-yt-paper-icon-button': ComponentProps<'div'> &
TpYtPaperIconButtonProps;
'yt-icon-button': ComponentProps<'div'> & TpYtPaperIconButtonProps;
'tp-yt-iron-icon': ComponentProps<'div'>;
'yt-icon': ComponentProps<'div'>;
// input type="range" slider component
'tp-yt-paper-slider': ComponentProps<'input'> & {
value?: number | string;
min?: number | string;
max?: number | string;
step?: number | string;
disabled?: boolean;
'on:immediate-value-changed'?: (
event: CustomEvent<{ value: number }>,
) => void;
};
'tp-yt-paper-progress': ComponentProps<'input'>;
}
}
}

View File

@ -20,13 +20,18 @@ export const pluginVirtualModuleGenerator = (
) => {
const srcPath = resolve(__dirname, '..', 'src');
const plugins = globSync([
'src/plugins/*/index.{js,ts}',
'src/plugins/*.{js,ts}',
'src/plugins/*/index.{js,ts,jsx,tsx}',
'src/plugins/*.{js,ts,jsx,tsx}',
'!src/plugins/utils/**/*',
'!src/plugins/utils/*',
]).map((path) => {
let name = basename(path);
if (name === 'index.ts' || name === 'index.js') {
if (
name === 'index.ts' ||
name === 'index.js' ||
name === 'index.jsx' ||
name === 'index.tsx'
) {
name = basename(resolve(path, '..'));
}