QOL: Move source code under the src directory. (#1318)

This commit is contained in:
Angelos Bouklis
2023-10-15 15:52:48 +03:00
committed by GitHub
parent 30c8dcf730
commit 7625a3aa52
159 changed files with 102 additions and 71 deletions

287
src/config/defaults.ts Normal file
View File

@ -0,0 +1,287 @@
import { blockers } from '../plugins/adblocker/blocker-types';
import { DefaultPresetList } from '../plugins/downloader/types';
export interface WindowSizeConfig {
width: number;
height: number;
}
export interface DefaultConfig {
'window-size': {
width: number;
height: number;
}
'window-maximized': boolean;
'window-position': {
x: number;
y: number;
}
url: string;
options: {
tray: boolean;
appVisible: boolean;
autoUpdates: boolean;
alwaysOnTop: boolean;
hideMenu: boolean;
hideMenuWarned: boolean;
startAtLogin: boolean;
disableHardwareAcceleration: boolean;
removeUpgradeButton: boolean;
restartOnConfigChanges: boolean;
trayClickPlayPause: boolean;
autoResetAppCache: boolean;
resumeOnStart: boolean;
likeButtons: string;
proxy: string;
startingPage: string;
overrideUserAgent: boolean;
themes: string[];
}
}
const defaultConfig = {
'window-size': {
width: 1100,
height: 550,
},
'window-maximized': false,
'window-position': {
x: -1,
y: -1,
},
'url': 'https://music.youtube.com',
'options': {
tray: false,
appVisible: true,
autoUpdates: true,
alwaysOnTop: false,
hideMenu: false,
hideMenuWarned: false,
startAtLogin: false,
disableHardwareAcceleration: false,
removeUpgradeButton: false,
restartOnConfigChanges: false,
trayClickPlayPause: false,
autoResetAppCache: false,
resumeOnStart: true,
likeButtons: '',
proxy: '',
startingPage: '',
overrideUserAgent: false,
themes: [] as string[],
},
/** please order alphabetically */
'plugins': {
'adblocker': {
enabled: true,
cache: true,
blocker: blockers.WithBlocklists as string,
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
disableDefaultLists: false,
},
'album-color-theme': {},
'ambient-mode': {},
'audio-compressor': {},
'blur-nav-bar': {},
'bypass-age-restrictions': {},
'captions-selector': {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
},
'compact-sidebar': {},
'crossfade': {
enabled: false,
fadeInDuration: 1500, // Ms
fadeOutDuration: 5000, // Ms
secondsBeforeEnd: 10, // S
fadeScaling: 'linear', // 'linear', 'logarithmic' or a positive number in dB
},
'disable-autoplay': {
applyOnce: false,
},
'discord': {
enabled: false,
autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
activityTimoutEnabled: true, // If enabled, the discord rich presence gets cleared when music paused after the time specified below
activityTimoutTime: 10 * 60 * 1000, // 10 minutes
listenAlong: true, // Add a "listen along" button to rich presence
hideGitHubButton: false, // Disable the "View App On GitHub" button
hideDurationLeft: false, // Hides the start and end time of the song to rich presence
},
'downloader': {
enabled: false,
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
selectedPreset: 'mp3 (256kbps)', // Selected preset
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
skipExisting: false,
playlistMaxItems: undefined as number | undefined,
},
'exponential-volume': {},
'in-app-menu': {
/**
* true in Windows, false in Linux and macOS (see youtube-music/config/store.ts)
*/
enabled: false,
},
'last-fm': {
enabled: false,
token: undefined as string | undefined, // Token used for authentication
session_key: undefined as string | undefined, // Session key used for scrobbling
api_root: 'http://ws.audioscrobbler.com/2.0/',
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
secret: 'a5d2a36fdf64819290f6982481eaffa2',
},
'lumiastream': {},
'lyrics-genius': {
romanizedLyrics: false,
},
'navigation': {
enabled: true,
},
'no-google-login': {},
'notifications': {
enabled: false,
unpauseNotification: false,
urgency: 'normal', // Has effect only on Linux
// the following has effect only on Windows
interactive: true,
toastStyle: 1, // See plugins/notifications/utils for more info
refreshOnPlayPause: false,
trayControls: true,
hideButtonText: false,
},
'picture-in-picture': {
'enabled': false,
'alwaysOnTop': true,
'savePosition': true,
'saveSize': false,
'hotkey': 'P',
'pip-position': [10, 10],
'pip-size': [450, 275],
'isInPiP': false,
'useNativePiP': false,
},
'playback-speed': {},
'precise-volume': {
enabled: false,
steps: 1, // Percentage of volume to change
arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts
globalShortcuts: {
volumeUp: '',
volumeDown: '',
},
savedVolume: undefined as number | undefined, // Plugin save volume between session here
},
'quality-changer': {},
'shortcuts': {
enabled: false,
overrideMediaKeys: false,
global: {
previous: '',
playPause: '',
next: '',
} as Record<string, string>,
local: {
previous: '',
playPause: '',
next: '',
} as Record<string, string>,
},
'skip-silences': {
onlySkipBeginning: false,
},
'sponsorblock': {
enabled: false,
apiURL: 'https://sponsor.ajay.app',
categories: [
'sponsor',
'intro',
'outro',
'interaction',
'selfpromo',
'music_offtopic',
],
},
'taskbar-mediacontrol': {},
'touchbar': {},
'tuna-obs': {},
'video-toggle': {
enabled: false,
hideVideo: false,
mode: 'custom',
forceHide: false,
align: '',
},
'visualizer': {
enabled: false,
type: 'butterchurn',
// Config per visualizer
butterchurn: {
preset: 'martin [shadow harlequins shape code] - fata morgana',
renderingFrequencyInMs: 500,
blendTimeInSeconds: 2.7,
},
vudio: {
effect: 'lighting',
accuracy: 128,
lighting: {
maxHeight: 160,
maxSize: 12,
lineWidth: 1,
color: '#49f3f7',
shadowBlur: 2,
shadowColor: 'rgba(244,244,244,.5)',
fadeSide: true,
prettify: false,
horizontalAlign: 'center',
verticalAlign: 'middle',
dottify: true,
},
},
wave: {
animations: [
{
type: 'Cubes',
config: {
bottom: true,
count: 30,
cubeHeight: 5,
fillColor: { gradient: ['#FAD961', '#F76B1C'] },
lineColor: 'rgba(0,0,0,0)',
radius: 20,
},
},
{
type: 'Cubes',
config: {
top: true,
count: 12,
cubeHeight: 5,
fillColor: { gradient: ['#FAD961', '#F76B1C'] },
lineColor: 'rgba(0,0,0,0)',
radius: 10,
},
},
{
type: 'Circles',
config: {
lineColor: {
gradient: ['#FAD961', '#FAD961', '#F76B1C'],
rotate: 90,
},
lineWidth: 4,
diameter: 20,
count: 10,
frequencyBand: 'base',
},
},
],
},
},
},
};
export default defaultConfig;

241
src/config/dynamic.ts Normal file
View File

@ -0,0 +1,241 @@
/* eslint-disable @typescript-eslint/require-await */
import { ipcMain, ipcRenderer } from 'electron';
import defaultConfig from './defaults';
import { getOptions, setMenuOptions, setOptions } from './plugins';
import { sendToFront } from '../providers/app-controls';
import { Entries } from '../utils/type-utils';
export type DefaultPluginsConfig = typeof defaultConfig.plugins;
export type OneOfDefaultConfigKey = keyof DefaultPluginsConfig;
export type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
export const getActivePlugins
= process.type === 'renderer'
? async () => ipcRenderer.invoke('get-active-plugins')
: () => activePlugins;
if (process.type === 'browser') {
ipcMain.handle('get-active-plugins', getActivePlugins);
}
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
export const isActive
= process.type === 'renderer'
? async (plugin: string) =>
plugin in (await ipcRenderer.invoke('get-active-plugins'))
: (plugin: string): boolean => plugin in activePlugins;
interface PluginConfigOptions {
enableFront: boolean;
initialOptions?: OneOfDefaultConfig;
}
/**
* This class is used to create a dynamic synced config for plugins.
*
* [!IMPORTANT!]
* The methods are **sync** in the main process and **async** in the renderer process.
*
* @param {string} name - The name of the plugin.
* @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false.
* @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store.
*
* @example
* const { PluginConfig } = require("../../config/dynamic");
* const config = new PluginConfig("plugin-name", { enableFront: true });
* module.exports = { ...config };
*
* // or
*
* module.exports = (win, options) => {
* const config = new PluginConfig("plugin-name", {
* enableFront: true,
* initialOptions: options,
* });
* setupMyPlugin(win, config);
* };
*/
export type ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T];
type ValueOf<T> = T[keyof T];
type Mode<T, Mode extends 'r' | 'm'> = Mode extends 'r' ? Promise<T> : T;
export class PluginConfig<T extends OneOfDefaultConfigKey> {
private readonly name: string;
private readonly config: ConfigType<T>;
private readonly defaultConfig: ConfigType<T>;
private readonly enableFront: boolean;
private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {};
private allSubscribers: ((config: ConfigType<T>) => void)[] = [];
constructor(
name: T,
options: PluginConfigOptions = {
enableFront: false,
},
) {
const pluginDefaultConfig = defaultConfig.plugins[name] ?? {};
const pluginConfig = options.initialOptions || getOptions(name) || {};
this.name = name;
this.enableFront = options.enableFront;
this.defaultConfig = pluginDefaultConfig;
this.config = { ...pluginDefaultConfig, ...pluginConfig };
if (this.enableFront) {
this.setupFront();
}
activePlugins[name] = this;
}
get<Key extends keyof ConfigType<T> = keyof ConfigType<T>>(key: Key): ConfigType<T>[Key] {
return this.config?.[key];
}
set(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
this.onChange(key);
this.save();
}
getAll(): ConfigType<T> {
return { ...this.config };
}
setAll(options: Partial<ConfigType<T>>) {
if (!options || typeof options !== 'object') {
throw new Error('Options must be an object.');
}
let changed = false;
for (const [key, value] of Object.entries(options) as Entries<typeof options>) {
if (this.config[key] !== value) {
if (value !== undefined) this.config[key] = value;
this.onChange(key, false);
changed = true;
}
}
if (changed) {
for (const fn of this.allSubscribers) {
fn(this.config);
}
}
this.save();
}
getDefaultConfig() {
return this.defaultConfig;
}
/**
* Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true`
*
* Used for options that require a restart to take effect.
*/
setAndMaybeRestart(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
setMenuOptions(this.name, this.config);
this.onChange(key);
}
subscribe(valueName: keyof ConfigType<T>, fn: (config: ConfigType<T>) => void) {
this.subscribers[valueName] = fn;
}
subscribeAll(fn: (config: ConfigType<T>) => void) {
this.allSubscribers.push(fn);
}
/** Called only from back */
private save() {
setOptions(this.name, this.config);
}
private onChange(valueName: keyof ConfigType<T>, single: boolean = true) {
this.subscribers[valueName]?.(this.config[valueName] as ConfigType<T>);
if (single) {
for (const fn of this.allSubscribers) {
fn(this.config);
}
}
}
private setupFront() {
const ignoredMethods = ['subscribe', 'subscribeAll'];
if (process.type === 'renderer') {
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-return
this[fnName] = (async (...args: any) => await ipcRenderer.invoke(
`${this.name}-config-${String(fnName)}`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
...args,
)) as typeof this[keyof this];
this.subscribe = (valueName, fn: (config: ConfigType<T>) => void) => {
if (valueName in this.subscribers) {
console.error(`Already subscribed to ${String(valueName)}`);
}
this.subscribers[valueName] = fn;
ipcRenderer.on(
`${this.name}-config-changed-${String(valueName)}`,
(_, value: ConfigType<T>) => {
fn(value);
},
);
ipcRenderer.send(`${this.name}-config-subscribe`, valueName);
};
this.subscribeAll = (fn: (config: ConfigType<T>) => void) => {
ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType<T>) => {
fn(value);
});
ipcRenderer.send(`${this.name}-config-subscribe-all`);
};
}
} else if (process.type === 'browser') {
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-return
ipcMain.handle(`${this.name}-config-${String(fnName)}`, (_, ...args) => fn(...args));
}
ipcMain.on(`${this.name}-config-subscribe`, (_, valueName: keyof ConfigType<T>) => {
this.subscribe(valueName, (value) => {
sendToFront(`${this.name}-config-changed-${String(valueName)}`, value);
});
});
ipcMain.on(`${this.name}-config-subscribe-all`, () => {
this.subscribeAll((value) => {
sendToFront(`${this.name}-config-changed`, value);
});
});
}
}
}

53
src/config/index.ts Normal file
View File

@ -0,0 +1,53 @@
import Store from 'electron-store';
import defaultConfig from './defaults';
import plugins from './plugins';
import store from './store';
import { restart } from '../providers/app-controls';
const set = (key: string, value: unknown) => {
store.set(key, value);
};
function setMenuOption(key: string, value: unknown) {
set(key, value);
if (store.get('options.restartOnConfigChanges')) {
restart();
}
}
// MAGIC OF TYPESCRIPT
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
type Join<K, P> = K extends string | number ?
P extends string | number ?
`${K}${'' extends P ? '' : '.'}${P}`
: never : never;
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
{ [K in keyof T]-?: K extends string | number ?
`${K}` | Join<K, Paths<T[K], Prev[D]>>
: never
}[keyof T] : ''
type SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
type PathValue<T, K extends string> =
SplitKey<K> extends [infer A extends keyof T, infer B extends string]
? PathValue<T[A], B>
: T;
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) => store.get(key) as PathValue<typeof defaultConfig, typeof key>;
export default {
defaultConfig,
get,
set,
setMenuOption,
edit: () => store.openInEditor(),
watch(cb: Parameters<Store['onDidChange']>[1]) {
store.onDidChange('options', cb);
store.onDidChange('plugins', cb);
},
plugins,
};

63
src/config/plugins.ts Normal file
View File

@ -0,0 +1,63 @@
import store from './store';
import defaultConfig from './defaults';
import { restart } from '../providers/app-controls';
import { Entries } from '../utils/type-utils';
interface Plugin {
enabled: boolean;
}
type DefaultPluginsConfig = typeof defaultConfig.plugins;
export function getEnabled() {
const plugins = store.get('plugins') as DefaultPluginsConfig;
return (Object.entries(plugins) as Entries<DefaultPluginsConfig>).filter(([plugin]) =>
isEnabled(plugin),
);
}
export function isEnabled(plugin: string) {
const pluginConfig = (store.get('plugins') as Record<string, Plugin>)[plugin];
return pluginConfig !== undefined && pluginConfig.enabled;
}
export function setOptions<T>(plugin: string, options: T) {
const plugins = store.get('plugins') as Record<string, T>;
store.set('plugins', {
...plugins,
[plugin]: {
...plugins[plugin],
...options,
},
});
}
export function setMenuOptions<T>(plugin: string, options: T) {
setOptions(plugin, options);
if (store.get('options.restartOnConfigChanges')) {
restart();
}
}
export function getOptions<T>(plugin: string): T {
return (store.get('plugins') as Record<string, T>)[plugin];
}
export function enable(plugin: string) {
setMenuOptions(plugin, { enabled: true });
}
export function disable(plugin: string) {
setMenuOptions(plugin, { enabled: false });
}
export default {
isEnabled,
getEnabled,
enable,
disable,
setOptions,
setMenuOptions,
getOptions,
};

154
src/config/store.ts Normal file
View File

@ -0,0 +1,154 @@
import Store from 'electron-store';
import Conf from 'conf';
import is from 'electron-is';
import defaults from './defaults';
import { DefaultPresetList, type Preset } from '../plugins/downloader/types';
const getDefaults = () => {
if (is.windows()) {
defaults.plugins['in-app-menu'].enabled = true;
}
return defaults;
};
const setDefaultPluginOptions = (store: Conf<Record<string, unknown>>, plugin: keyof typeof defaults.plugins) => {
if (!store.get(`plugins.${plugin}`)) {
store.set(`plugins.${plugin}`, defaults.plugins[plugin]);
}
};
const migrations = {
'>=2.1.0'(store: Conf<Record<string, unknown>>) {
const originalPreset = store.get('plugins.downloader.preset') as string | undefined;
if (originalPreset) {
if (originalPreset !== 'opus') {
store.set('plugins.downloader.selectedPreset', 'Custom');
store.set('plugins.downloader.customPresetSetting', {
extension: 'mp3',
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? DefaultPresetList['mp3 (256kbps)'].ffmpegArgs,
} satisfies Preset);
} else {
store.set('plugins.downloader.selectedPreset', 'Source');
store.set('plugins.downloader.customPresetSetting', {
extension: null,
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [],
} satisfies Preset);
}
store.delete('plugins.downloader.preset');
store.delete('plugins.downloader.ffmpegArgs');
}
},
'>=1.20.0'(store: Conf<Record<string, unknown>>) {
setDefaultPluginOptions(store, 'visualizer');
if (store.get('plugins.notifications.toastStyle') === undefined) {
const pluginOptions = store.get('plugins.notifications') || {};
store.set('plugins.notifications', {
...defaults.plugins.notifications,
...pluginOptions,
});
}
if (store.get('options.ForceShowLikeButtons')) {
store.delete('options.ForceShowLikeButtons');
store.set('options.likeButtons', 'force');
}
},
'>=1.17.0'(store: Conf<Record<string, unknown>>) {
setDefaultPluginOptions(store, 'picture-in-picture');
if (store.get('plugins.video-toggle.mode') === undefined) {
store.set('plugins.video-toggle.mode', 'custom');
}
},
'>=1.14.0'(store: Conf<Record<string, unknown>>) {
if (
typeof store.get('plugins.precise-volume.globalShortcuts') !== 'object'
) {
store.set('plugins.precise-volume.globalShortcuts', {});
}
if (store.get('plugins.hide-video-player.enabled')) {
store.delete('plugins.hide-video-player');
store.set('plugins.video-toggle.enabled', true);
}
},
'>=1.13.0'(store: Conf<Record<string, unknown>>) {
if (store.get('plugins.discord.listenAlong') === undefined) {
store.set('plugins.discord.listenAlong', true);
}
},
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
const options = store.get('plugins.shortcuts') as Record<string, {
action: string;
shortcut: unknown;
}[] | Record<string, unknown>>;
let updated = false;
for (const optionType of ['global', 'local']) {
if (Array.isArray(options[optionType])) {
const optionsArray = options[optionType] as {
action: string;
shortcut: unknown;
}[];
const updatedOptions: Record<string, unknown> = {};
for (const optionObject of optionsArray) {
if (optionObject.action && optionObject.shortcut) {
updatedOptions[optionObject.action] = optionObject.shortcut;
}
}
options[optionType] = updatedOptions;
updated = true;
}
}
if (updated) {
store.set('plugins.shortcuts', options);
}
},
'>=1.11.0'(store: Conf<Record<string, unknown>>) {
if (store.get('options.resumeOnStart') === undefined) {
store.set('options.resumeOnStart', true);
}
},
'>=1.7.0'(store: Conf<Record<string, unknown>>) {
const enabledPlugins = store.get('plugins') as string[];
if (!Array.isArray(enabledPlugins)) {
console.warn('Plugins are not in array format, cannot migrate');
return;
}
// Include custom options
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const plugins: Record<string, any> = {
adblocker: {
enabled: true,
cache: true,
additionalBlockLists: [],
},
downloader: {
enabled: false,
ffmpegArgs: [], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
downloadFolder: undefined, // Custom download folder (absolute path)
},
};
for (const enabledPlugin of enabledPlugins) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
plugins[enabledPlugin] = {
...plugins[enabledPlugin],
enabled: true,
};
}
store.set('plugins', plugins);
},
};
export default new Store({
defaults: getDefaults(),
clearInvalidConfig: false,
migrations,
});

85
src/custom-electron-prompt.d.ts vendored Normal file
View File

@ -0,0 +1,85 @@
declare module 'custom-electron-prompt' {
import { BrowserWindow } from 'electron';
export type SelectOptions = Record<string, string>;
export interface CounterOptions {
minimum?: number;
maximum?: number;
multiFire?: boolean;
}
export interface KeybindOptions {
value: string;
label: string;
default?: string;
}
export interface InputOptions {
label: string;
value: unknown;
inputAttrs?: Partial<HTMLInputElement>;
selectOptions?: SelectOptions;
}
interface BasePromptOptions<T extends string> {
type?: T;
width?: number;
height?: number;
resizable?: boolean;
title?: string;
label?: string;
buttonLabels?: {
ok?: string;
cancel?: string;
};
alwaysOnTop?: boolean;
value?: unknown;
icon?: string;
useHtmlLabel?: boolean;
customStylesheet?: string;
menuBarVisible?: boolean;
skipTaskbar?: boolean;
frame?: boolean;
customScript?: string;
enableRemoteModule?: boolean;
inputAttrs?: Partial<HTMLInputElement>;
}
export type InputPromptOptions = BasePromptOptions<'input'>;
export interface SelectPromptOptions extends BasePromptOptions<'select'> {
selectOptions: SelectOptions;
}
export interface CounterPromptOptions extends BasePromptOptions<'counter'> {
counterOptions: CounterOptions;
}
export interface MultiInputPromptOptions extends BasePromptOptions<'multiInput'> {
multiInputOptions: InputOptions[];
}
export interface KeybindPromptOptions extends BasePromptOptions<'keybind'> {
keybindOptions: KeybindOptions[];
}
export type PromptOptions<T extends string> = (
T extends 'input' ? InputPromptOptions :
T extends 'select' ? SelectPromptOptions :
T extends 'counter' ? CounterPromptOptions :
T extends 'keybind' ? KeybindPromptOptions :
T extends 'multiInput' ? MultiInputPromptOptions :
never
);
type PromptResult<T extends string> = T extends 'input' ? string :
T extends 'select' ? string :
T extends 'counter' ? number :
T extends 'keybind' ? {
value: string;
accelerator: string
}[] :
T extends 'multiInput' ? string[] :
never;
const prompt: <T extends Type>(options?: PromptOptions<T> & { type: T }, parent?: BrowserWindow) => Promise<PromptResult<T> | null>;
export default prompt;
}

50
src/error.html Normal file
View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Cannot load YouTube Music</title>
<style>
body {
background: #000;
}
.container {
margin: 0;
font-family: Roboto, Arial, sans-serif;
font-size: 20px;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
position: absolute;
top: 50%;
left: 50%;
margin-right: -50%;
transform: translate(-50%, -50%);
text-align: center;
}
.button {
background: #065fd4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: white;
font: inherit;
text-transform: uppercase;
text-decoration: none;
border-radius: 2px;
font-size: 16px;
font-weight: normal;
text-align: center;
padding: 8px 22px;
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<p>Cannot load YouTube Music… Internet disconnected?</p>
<a class="button" href="#" onclick="reload()">Retry</a>
</div>
</body>
</html>

639
src/index.ts Normal file
View File

@ -0,0 +1,639 @@
import path from 'node:path';
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request';
import is from 'electron-is';
import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater';
import electronDebug from 'electron-debug';
import config from './config';
import { refreshMenu, setApplicationMenu } from './menu';
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils';
import { isTesting } from './utils/testing';
import { setUpTray } from './tray';
import { setupSongInfo } from './providers/song-info';
import { restart, setupAppControls } from './providers/app-controls';
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler';
import adblocker from './plugins/adblocker/back';
import albumColorTheme from './plugins/album-color-theme/back';
import ambientMode from './plugins/ambient-mode/back';
import blurNavigationBar from './plugins/blur-nav-bar/back';
import captionsSelector from './plugins/captions-selector/back';
import crossfade from './plugins/crossfade/back';
import discord from './plugins/discord/back';
import downloader from './plugins/downloader/back';
import inAppMenu from './plugins/in-app-menu/back';
import lastFm from './plugins/last-fm/back';
import lumiaStream from './plugins/lumiastream/back';
import lyricsGenius from './plugins/lyrics-genius/back';
import navigation from './plugins/navigation/back';
import noGoogleLogin from './plugins/no-google-login/back';
import notifications from './plugins/notifications/back';
import pictureInPicture, { setOptions as pipSetOptions } from './plugins/picture-in-picture/back';
import preciseVolume from './plugins/precise-volume/back';
import qualityChanger from './plugins/quality-changer/back';
import shortcuts from './plugins/shortcuts/back';
import sponsorBlock from './plugins/sponsorblock/back';
import taskbarMediaControl from './plugins/taskbar-mediacontrol/back';
import touchbar from './plugins/touchbar/back';
import tunaObs from './plugins/tuna-obs/back';
import videoToggle from './plugins/video-toggle/back';
import visualizer from './plugins/visualizer/back';
import youtubeMusicCSS from './youtube-music.css';
// Catch errors and log them
unhandled({
logger: console.error,
showDialog: false,
});
// Disable Node options if the env var is set
process.env.NODE_OPTIONS = '';
// Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow | null;
autoUpdater.autoDownload = false;
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.exit();
}
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
// OverlayScrollbar: Required for overlay scrollbars
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer');
if (config.get('options.disableHardwareAcceleration')) {
if (is.dev()) {
console.log('Disabling hardware acceleration');
}
app.disableHardwareAcceleration();
}
if (is.linux() && config.plugins.isEnabled('shortcuts')) {
// Stops chromium from launching its own MPRIS service
app.commandLine.appendSwitch('disable-features', 'MediaSessionService');
}
if (config.get('options.proxy')) {
app.commandLine.appendSwitch('proxy-server', config.get('options.proxy'));
}
// Adds debug features like hotkeys for triggering dev tools and reload
electronDebug({
showDevTools: false, // Disable automatic devTools on new window
});
let icon = 'assets/youtube-music.png';
if (process.platform === 'win32') {
icon = 'assets/generated/icon.ico';
} else if (process.platform === 'darwin') {
icon = 'assets/generated/icon.icns';
}
function onClosed() {
// Dereference the window
// For multiple Windows store them in an array
mainWindow = null;
}
const mainPlugins = {
'adblocker': adblocker,
'album-color-theme': albumColorTheme,
'ambient-mode': ambientMode,
'blur-nav-bar': blurNavigationBar,
'captions-selector': captionsSelector,
'crossfade': crossfade,
'discord': discord,
'downloader': downloader,
'in-app-menu': inAppMenu,
'last-fm': lastFm,
'lumiastream': lumiaStream,
'lyrics-genius': lyricsGenius,
'navigation': navigation,
'no-google-login': noGoogleLogin,
'notifications': notifications,
'picture-in-picture': pictureInPicture,
'precise-volume': preciseVolume,
'quality-changer': qualityChanger,
'shortcuts': shortcuts,
'sponsorblock': sponsorBlock,
'taskbar-mediacontrol': undefined as typeof taskbarMediaControl | undefined,
'touchbar': undefined as typeof touchbar | undefined,
'tuna-obs': tunaObs,
'video-toggle': videoToggle,
'visualizer': visualizer,
};
export const mainPluginNames = Object.keys(mainPlugins);
if (is.windows()) {
mainPlugins['taskbar-mediacontrol'] = taskbarMediaControl;
delete mainPlugins['touchbar'];
} else if (is.macOS()) {
mainPlugins['touchbar'] = touchbar;
delete mainPlugins['taskbar-mediacontrol'];
} else {
delete mainPlugins['touchbar'];
delete mainPlugins['taskbar-mediacontrol'];
}
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
async function loadPlugins(win: BrowserWindow) {
injectCSS(win.webContents, youtubeMusicCSS);
// Load user CSS
const themes: string[] = config.get('options.themes');
if (Array.isArray(themes)) {
for (const cssFile of themes) {
fileExists(
cssFile,
() => {
injectCSSAsFile(win.webContents, cssFile);
},
() => {
console.warn(`CSS file "${cssFile}" does not exist, ignoring`);
},
);
}
}
win.webContents.once('did-finish-load', () => {
if (is.dev()) {
console.log('did finish load');
win.webContents.openDevTools();
}
});
for (const [plugin, options] of config.plugins.getEnabled()) {
try {
if (Object.hasOwn(mainPlugins, plugin)) {
console.log('Loaded plugin - ' + plugin);
const handler = mainPlugins[plugin as keyof typeof mainPlugins];
if (handler) {
await handler(win, options as never);
}
}
} catch (e) {
console.error(`Failed to load plugin "${plugin}"`, e);
}
}
}
async function createMainWindow() {
const windowSize = config.get('window-size');
const windowMaximized = config.get('window-maximized');
const windowPosition: Electron.Point = config.get('window-position');
const useInlineMenu = config.plugins.isEnabled('in-app-menu');
const win = new BrowserWindow({
icon,
width: windowSize.width,
height: windowSize.height,
backgroundColor: '#000',
show: false,
webPreferences: {
// TODO: re-enable contextIsolation once it can work with FFMpeg.wasm
// Possible bundling? https://github.com/ffmpegwasm/ffmpeg.wasm/issues/126
contextIsolation: false,
preload: path.join(__dirname, 'preload.js'),
nodeIntegrationInSubFrames: true,
...(isTesting()
? undefined
: {
// Sandbox is only enabled in tests for now
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
sandbox: false,
}),
},
frame: !is.macOS() && !useInlineMenu,
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 36,
},
titleBarStyle: useInlineMenu
? 'hidden'
: (is.macOS()
? 'hiddenInset'
: 'default'),
autoHideMenuBar: config.get('options.hideMenu'),
});
await loadPlugins(win);
if (windowPosition) {
const { x: windowX, y: windowY } = windowPosition;
const winSize = win.getSize();
const displaySize
= screen.getDisplayNearestPoint(windowPosition).bounds;
if (
windowX + winSize[0] < displaySize.x - 8
|| windowX - winSize[0] > displaySize.x + displaySize.width
|| windowY < displaySize.y - 8
|| windowY > displaySize.y + displaySize.height
) {
// Window is offscreen
if (is.dev()) {
console.log(
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`,
);
}
} else {
win.setPosition(windowX, windowY);
}
}
if (windowMaximized) {
win.maximize();
}
if (config.get('options.alwaysOnTop')) {
win.setAlwaysOnTop(true);
}
const urlToLoad = config.get('options.resumeOnStart')
? config.get('url')
: config.defaultConfig.url;
win.on('closed', onClosed);
type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture'];
const setPiPOptions = config.plugins.isEnabled('picture-in-picture')
// eslint-disable-next-line @typescript-eslint/no-var-requires
? (key: string, value: unknown) => pipSetOptions({ [key]: value })
: () => {};
win.on('move', () => {
if (win.isMaximized()) {
return;
}
const position = win.getPosition();
const isPiPEnabled: boolean
= config.plugins.isEnabled('picture-in-picture')
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
if (!isPiPEnabled) {
lateSave('window-position', { x: position[0], y: position[1] });
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').savePosition) {
lateSave('pip-position', position, setPiPOptions);
}
});
let winWasMaximized: boolean;
win.on('resize', () => {
const windowSize = win.getSize();
const isMaximized = win.isMaximized();
const isPiPEnabled
= config.plugins.isEnabled('picture-in-picture')
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
winWasMaximized = isMaximized;
config.set('window-maximized', isMaximized);
}
if (isMaximized) {
return;
}
if (!isPiPEnabled) {
lateSave('window-size', {
width: windowSize[0],
height: windowSize[1],
});
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').saveSize) {
lateSave('pip-size', windowSize, setPiPOptions);
}
});
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) {
if (savedTimeouts[key]) {
clearTimeout(savedTimeouts[key]);
}
savedTimeouts[key] = setTimeout(() => {
fn(key, value);
savedTimeouts[key] = undefined;
}, 600);
}
app.on('render-process-gone', (event, webContents, details) => {
showUnresponsiveDialog(win, details);
});
win.once('ready-to-show', () => {
if (config.get('options.appVisible')) {
win.show();
}
});
removeContentSecurityPolicy();
win.webContents.loadURL(urlToLoad);
return win;
}
app.once('browser-window-created', (event, win) => {
if (config.get('options.overrideUserAgent')) {
// User agents are from https://developers.whatismybrowser.com/useragents/explore/
const originalUserAgent = win.webContents.userAgent;
const userAgents = {
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0',
windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
};
const updatedUserAgent
= is.macOS() ? userAgents.mac
: (is.windows() ? userAgents.windows
: userAgents.linux);
win.webContents.userAgent = updatedUserAgent;
app.userAgentFallback = updatedUserAgent;
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
// This will only happen if login failed, and "retry" was pressed
if (win.webContents.getURL().startsWith('https://accounts.google.com') && details.url.startsWith('https://accounts.google.com')) {
details.requestHeaders['User-Agent'] = originalUserAgent;
}
cb({ requestHeaders: details.requestHeaders });
});
}
setupSongInfo(win);
setupAppControls();
win.webContents.on('did-fail-load', (
_event,
errorCode,
errorDescription,
validatedURL,
isMainFrame,
frameProcessId,
frameRoutingId,
) => {
const log = JSON.stringify({
error: 'did-fail-load',
errorCode,
errorDescription,
validatedURL,
isMainFrame,
frameProcessId,
frameRoutingId,
}, null, '\t');
if (is.dev()) {
console.log(log);
}
if (errorCode !== -3) { // -3 is a false positive
win.webContents.send('log', log);
win.webContents.loadFile(path.join(__dirname, 'error.html'));
}
});
win.webContents.on('will-prevent-unload', (event) => {
event.preventDefault();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
// Unregister all shortcuts.
globalShortcut.unregisterAll();
});
app.on('activate', async () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) {
mainWindow = await createMainWindow();
} else if (!mainWindow.isVisible()) {
mainWindow.show();
}
});
app.on('ready', async () => {
if (config.get('options.autoResetAppCache')) {
// Clear cache after 20s
const clearCacheTimeout = setTimeout(() => {
if (is.dev()) {
console.log('Clearing app cache.');
}
session.defaultSession.clearCache();
clearTimeout(clearCacheTimeout);
}, 20_000);
}
// Register appID on windows
if (is.windows()) {
const appID = 'com.github.th-ch.youtube-music';
app.setAppUserModelId(appID);
const appLocation = process.execPath;
const appData = app.getPath('appData');
// Check shortcut validity if not in dev mode / running portable app
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) {
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk');
try { // Check if shortcut is registered and valid
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
if (
shortcutDetails.target !== appLocation
|| shortcutDetails.appUserModelId !== appID
) {
throw 'needUpdate';
}
} catch (error) { // If not valid -> Register shortcut
shell.writeShortcutLink(
shortcutPath,
error === 'needUpdate' ? 'update' : 'create',
{
target: appLocation,
cwd: path.dirname(appLocation),
description: 'YouTube Music Desktop App - including custom plugins',
appUserModelId: appID,
},
);
}
}
}
mainWindow = await createMainWindow();
setApplicationMenu(mainWindow);
refreshMenu(mainWindow);
setUpTray(app, mainWindow);
setupProtocolHandler(mainWindow);
app.on('second-instance', (_, commandLine) => {
const uri = `${APP_PROTOCOL}://`;
const protocolArgv = commandLine.find((arg) => arg.startsWith(uri));
if (protocolArgv) {
const lastIndex = protocolArgv.endsWith('/') ? -1 : undefined;
const command = protocolArgv.slice(uri.length, lastIndex);
if (is.dev()) {
console.debug(`Received command over protocol: "${command}"`);
}
handleProtocol(command);
return;
}
if (!mainWindow) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
});
// Autostart at login
app.setLoginItemSettings({
openAtLogin: config.get('options.startAtLogin'),
});
if (!is.dev() && config.get('options.autoUpdates')) {
const updateTimeout = setTimeout(() => {
autoUpdater.checkForUpdatesAndNotify();
clearTimeout(updateTimeout);
}, 2000);
autoUpdater.on('update-available', () => {
const downloadLink
= 'https://github.com/th-ch/youtube-music/releases/latest';
const dialogOptions: Electron.MessageBoxOptions = {
type: 'info',
buttons: ['OK', 'Download', 'Disable updates'],
title: 'Application Update',
message: 'A new version is available',
detail: `A new version is available and can be downloaded at ${downloadLink}`,
};
dialog.showMessageBox(dialogOptions).then((dialogOutput) => {
switch (dialogOutput.response) {
// Download
case 1: {
shell.openExternal(downloadLink);
break;
}
// Disable updates
case 2: {
config.set('options.autoUpdates', false);
break;
}
default: {
break;
}
}
});
});
}
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(mainWindow, {
type: 'info', title: 'Hide Menu Enabled',
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
});
config.set('options.hideMenuWarned', true);
}
// Optimized for Mac OS X
if (is.macOS() && !config.get('options.appVisible')) {
app.dock.hide();
}
let forceQuit = false;
app.on('before-quit', () => {
forceQuit = true;
});
if (is.macOS() || config.get('options.tray')) {
mainWindow.on('close', (event) => {
// Hide the window instead of quitting (quit is available in tray options)
if (!forceQuit) {
event.preventDefault();
mainWindow!.hide();
}
});
}
});
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) {
if (details) {
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
}
dialog.showMessageBox(win, {
type: 'error',
title: 'Window Unresponsive',
message: 'The Application is Unresponsive',
detail: 'We are sorry for the inconvenience! please choose what to do:',
buttons: ['Wait', 'Relaunch', 'Quit'],
cancelId: 0,
}).then((result) => {
switch (result.response) {
case 1: {
restart();
break;
}
case 2: {
app.quit();
break;
}
}
});
}
function removeContentSecurityPolicy(
betterSession: BetterSession = session.defaultSession as BetterSession,
) {
// Allows defining multiple "onHeadersReceived" listeners
// by enhancing the session.
// Some plugins (e.g. adblocker) also define a "onHeadersReceived" listener
enhanceWebRequest(betterSession);
// Custom listener to tweak the content security policy
betterSession.webRequest.onHeadersReceived((details, callback) => {
details.responseHeaders ??= {};
// Remove the content security policy
delete details.responseHeaders['content-security-policy-report-only'];
delete details.responseHeaders['content-security-policy'];
callback({ cancel: false, responseHeaders: details.responseHeaders });
});
// When multiple listeners are defined, apply them all
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => {
return listeners.reduce(
async (accumulator, listener) => {
const acc = await accumulator;
if (acc.cancel) {
return acc;
}
const result = await listener.apply();
return { ...accumulator, ...result };
},
Promise.resolve({ cancel: false }),
);
});
}

480
src/menu.ts Normal file
View File

@ -0,0 +1,480 @@
import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
import prompt from 'custom-electron-prompt';
import { restart } from './providers/app-controls';
import config from './config';
import { startingPages } from './providers/extracted-data';
import promptOptions from './providers/prompt-options';
import adblockerMenu from './plugins/adblocker/menu';
import captionsSelectorMenu from './plugins/captions-selector/menu';
import crossfadeMenu from './plugins/crossfade/menu';
import disableAutoplayMenu from './plugins/disable-autoplay/menu';
import discordMenu from './plugins/discord/menu';
import downloaderMenu from './plugins/downloader/menu';
import lyricsGeniusMenu from './plugins/lyrics-genius/menu';
import notificationsMenu from './plugins/notifications/menu';
import pictureInPictureMenu from './plugins/picture-in-picture/menu';
import preciseVolumeMenu from './plugins/precise-volume/menu';
import shortcutsMenu from './plugins/shortcuts/menu';
import videoToggleMenu from './plugins/video-toggle/menu';
import visualizerMenu from './plugins/visualizer/menu';
import { getAvailablePluginNames } from './plugins/utils';
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
// True only if in-app-menu was loaded on launch
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
const betaPlugins = ['crossfade', 'lumiastream'];
const pluginMenus = {
'adblocker': adblockerMenu,
'disable-autoplay': disableAutoplayMenu,
'captions-selector': captionsSelectorMenu,
'crossfade': crossfadeMenu,
'discord': discordMenu,
'downloader': downloaderMenu,
'lyrics-genius': lyricsGeniusMenu,
'notifications': notificationsMenu,
'picture-in-picture': pictureInPictureMenu,
'precise-volume': preciseVolumeMenu,
'shortcuts': shortcutsMenu,
'video-toggle': videoToggleMenu,
'visualizer': visualizerMenu,
};
const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
label: label || plugin,
type: 'checkbox',
checked: config.plugins.isEnabled(plugin),
click(item: Electron.MenuItem) {
if (item.checked) {
config.plugins.enable(plugin);
} else {
config.plugins.disable(plugin);
}
if (hasSubmenu) {
refreshMenu?.();
}
},
});
export const refreshMenu = (win: BrowserWindow) => {
setApplicationMenu(win);
if (inAppMenuActive) {
win.webContents.send('refreshMenu');
}
};
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
const innerRefreshMenu = () => refreshMenu(win);
return [
{
label: 'Plugins',
submenu:
getAvailablePluginNames().map((pluginName) => {
let pluginLabel = pluginName;
if (betaPlugins.includes(pluginLabel)) {
pluginLabel += ' [beta]';
}
if (Object.hasOwn(pluginMenus, pluginName)) {
const getPluginMenu = pluginMenus[pluginName as keyof typeof pluginMenus];
if (!config.plugins.isEnabled(pluginName)) {
return pluginEnabledMenu(pluginName, pluginLabel, true, innerRefreshMenu);
}
return {
label: pluginLabel,
submenu: [
pluginEnabledMenu(pluginName, 'Enabled', true, innerRefreshMenu),
{ type: 'separator' },
...getPluginMenu(win, config.plugins.getOptions(pluginName), innerRefreshMenu),
],
} satisfies Electron.MenuItemConstructorOptions;
}
return pluginEnabledMenu(pluginName, pluginLabel);
}),
},
{
label: 'Options',
submenu: [
{
label: 'Auto-update',
type: 'checkbox',
checked: config.get('options.autoUpdates'),
click(item) {
config.setMenuOption('options.autoUpdates', item.checked);
},
},
{
label: 'Resume last song when app starts',
type: 'checkbox',
checked: config.get('options.resumeOnStart'),
click(item) {
config.setMenuOption('options.resumeOnStart', item.checked);
},
},
{
label: 'Starting page',
submenu: (() => {
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({
label: name,
type: 'radio',
checked: config.get('options.startingPage') === name,
click() {
config.set('options.startingPage', name);
},
}));
subMenuArray.unshift({
label: 'Unset',
type: 'radio',
checked: config.get('options.startingPage') === '',
click() {
config.set('options.startingPage', '');
},
});
return subMenuArray;
})(),
},
{
label: 'Visual Tweaks',
submenu: [
{
label: 'Remove upgrade button',
type: 'checkbox',
checked: config.get('options.removeUpgradeButton'),
click(item) {
config.setMenuOption('options.removeUpgradeButton', item.checked);
},
},
{
label: 'Like buttons',
submenu: [
{
label: 'Default',
type: 'radio',
checked: !config.get('options.likeButtons'),
click() {
config.set('options.likeButtons', '');
},
},
{
label: 'Force show',
type: 'radio',
checked: config.get('options.likeButtons') === 'force',
click() {
config.set('options.likeButtons', 'force');
},
},
{
label: 'Hide',
type: 'radio',
checked: config.get('options.likeButtons') === 'hide',
click() {
config.set('options.likeButtons', 'hide');
},
},
],
},
{
label: 'Theme',
submenu: [
{
label: 'No theme',
type: 'radio',
checked: config.get('options.themes')?.length === 0, // Todo rename "themes"
click() {
config.set('options.themes', []);
},
},
{ type: 'separator' },
{
label: 'Import custom CSS file',
type: 'normal',
async click() {
const { filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'CSS Files', extensions: ['css'] }],
properties: ['openFile', 'multiSelections'],
});
if (filePaths) {
config.set('options.themes', filePaths);
}
},
},
],
},
],
},
{
label: 'Single instance lock',
type: 'checkbox',
checked: true,
click(item) {
if (!item.checked && app.hasSingleInstanceLock()) {
app.releaseSingleInstanceLock();
} else if (item.checked && !app.hasSingleInstanceLock()) {
app.requestSingleInstanceLock();
}
},
},
{
label: 'Always on top',
type: 'checkbox',
checked: config.get('options.alwaysOnTop'),
click(item) {
config.setMenuOption('options.alwaysOnTop', item.checked);
win.setAlwaysOnTop(item.checked);
},
},
...(is.windows() || is.linux()
? [
{
label: 'Hide menu',
type: 'checkbox',
checked: config.get('options.hideMenu'),
click(item) {
config.setMenuOption('options.hideMenu', item.checked);
if (item.checked && !config.get('options.hideMenuWarned')) {
dialog.showMessageBox(win, {
type: 'info', title: 'Hide Menu Enabled',
message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)',
});
}
},
},
]
: []) satisfies Electron.MenuItemConstructorOptions[],
...(is.windows() || is.macOS()
? // Only works on Win/Mac
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
[
{
label: 'Start at login',
type: 'checkbox',
checked: config.get('options.startAtLogin'),
click(item) {
config.setMenuOption('options.startAtLogin', item.checked);
},
},
]
: []) satisfies Electron.MenuItemConstructorOptions[],
{
label: 'Tray',
submenu: [
{
label: 'Disabled',
type: 'radio',
checked: !config.get('options.tray'),
click() {
config.setMenuOption('options.tray', false);
config.setMenuOption('options.appVisible', true);
},
},
{
label: 'Enabled + app visible',
type: 'radio',
checked: config.get('options.tray') && config.get('options.appVisible'),
click() {
config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', true);
},
},
{
label: 'Enabled + app hidden',
type: 'radio',
checked: config.get('options.tray') && !config.get('options.appVisible'),
click() {
config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', false);
},
},
{ type: 'separator' },
{
label: 'Play/Pause on click',
type: 'checkbox',
checked: config.get('options.trayClickPlayPause'),
click(item) {
config.setMenuOption('options.trayClickPlayPause', item.checked);
},
},
],
},
{ type: 'separator' },
{
label: 'Advanced options',
submenu: [
{
label: 'Set Proxy',
type: 'normal',
async click(item) {
await setProxy(item, win);
},
},
{
label: 'Override useragent',
type: 'checkbox',
checked: config.get('options.overrideUserAgent'),
click(item) {
config.setMenuOption('options.overrideUserAgent', item.checked);
},
},
{
label: 'Disable hardware acceleration',
type: 'checkbox',
checked: config.get('options.disableHardwareAcceleration'),
click(item) {
config.setMenuOption('options.disableHardwareAcceleration', item.checked);
},
},
{
label: 'Restart on config changes',
type: 'checkbox',
checked: config.get('options.restartOnConfigChanges'),
click(item) {
config.setMenuOption('options.restartOnConfigChanges', item.checked);
},
},
{
label: 'Reset App cache when app starts',
type: 'checkbox',
checked: config.get('options.autoResetAppCache'),
click(item) {
config.setMenuOption('options.autoResetAppCache', item.checked);
},
},
{ type: 'separator' },
is.macOS()
? {
label: 'Toggle DevTools',
// Cannot use "toggleDevTools" role in macOS
click() {
const { webContents } = win;
if (webContents.isDevToolsOpened()) {
webContents.closeDevTools();
} else {
webContents.openDevTools();
}
},
}
: { role: 'toggleDevTools' },
{
label: 'Edit config.json',
click() {
config.edit();
},
},
],
},
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ type: 'separator' },
{ role: 'zoomIn', accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I' },
{ role: 'zoomOut', accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O' },
{ role: 'resetZoom' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Navigation',
submenu: [
{
label: 'Go back',
click() {
if (win.webContents.canGoBack()) {
win.webContents.goBack();
}
},
},
{
label: 'Go forward',
click() {
if (win.webContents.canGoForward()) {
win.webContents.goForward();
}
},
},
{
label: 'Copy current URL',
click() {
const currentURL = win.webContents.getURL();
clipboard.writeText(currentURL);
},
},
{
label: 'Restart App',
click: restart,
},
{ role: 'quit' },
],
},
{
label: 'About',
submenu: [
{ role: 'about' },
],
}
];
};
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
if (process.platform === 'darwin') {
const { name } = app;
menuTemplate.unshift({
label: name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'selectAll' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ type: 'separator' },
{ role: 'minimize' },
{ role: 'close' },
{ role: 'quit' },
],
});
}
const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu);
};
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
const output = await prompt({
title: 'Set Proxy',
label: 'Enter Proxy Address: (leave empty to disable)',
value: config.get('options.proxy'),
type: 'input',
inputAttrs: {
type: 'url',
placeholder: "Example: 'socks5://127.0.0.1:9999",
},
width: 450,
...promptOptions(),
}, win);
if (typeof output === 'string') {
config.setMenuOption('options.proxy', output);
item.checked = output !== '';
} else { // User pressed cancel
item.checked = !item.checked; // Reset checkbox
}
}

88
src/navigation.d.ts vendored Normal file
View File

@ -0,0 +1,88 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface NavigationOptions {
info: any;
}
interface NavigationHistoryEntry extends EventTarget {
readonly url?: string;
readonly key: string;
readonly id: string;
readonly index: number;
readonly sameDocument: boolean;
getState(): any;
ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null;
}
interface NavigationTransition {
readonly navigationType: NavigationType;
readonly from: NavigationHistoryEntry;
readonly finished: Promise<undefined>;
}
interface NavigationResult {
committed: Promise<NavigationHistoryEntry>;
finished: Promise<NavigationHistoryEntry>;
}
interface NavigationNavigateOptions extends NavigationOptions {
state: any;
history?: NavigationHistoryBehavior;
}
interface NavigationReloadOptions extends NavigationOptions {
state: any;
}
interface NavigationUpdateCurrentEntryOptions {
state: any;
}
interface NavigationEventsMap {
currententrychange: NavigateEvent;
navigate: NavigateEvent;
navigateerror: NavigateEvent;
navigatesuccess: NavigateEvent;
}
interface Navigation extends EventTarget {
entries(): Array<NavigationHistoryEntry>;
readonly currentEntry?: NavigationHistoryEntry;
updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): undefined;
readonly transition?: NavigationTransition;
readonly canGoBack: boolean;
readonly canGoForward: boolean;
navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
reload(options?: NavigationReloadOptions): NavigationResult;
traverseTo(key: string, options?: NavigationOptions): NavigationResult;
back(options?: NavigationOptions): NavigationResult;
forward(options?: NavigationOptions): NavigationResult;
onnavigate: ((this: Navigation, ev: Event) => any) | null;
onnavigatesuccess: ((this: Navigation, ev: Event) => any) | null;
onnavigateerror: ((this: Navigation, ev: Event) => any) | null;
oncurrententrychange: ((this: Navigation, ev: Event) => any) | null;
addEventListener<K extends keyof NavigationEventsMap>(name: K, listener: (event: NavigationEventsMap[K]) => void);
}
declare class NavigateEvent extends Event {
canIntercept: boolean;
destination: NavigationHistoryEntry;
downloadRequest: string | null;
formData: FormData;
hashChange: boolean;
info: Record<string, string>;
navigationType: 'push' | 'reload' | 'replace' | 'traverse';
signal: AbortSignal;
userInitiated: boolean;
intercept(options?: Record<string, unknown>): void;
scroll(): void;
}
type NavigationHistoryBehavior = 'auto' | 'push' | 'replace';
declare const Navigation: {
prototype: Navigation;
new(): Navigation;
};

1
src/plugins/adblocker/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/ad-blocker-engine.bin

View File

@ -0,0 +1,19 @@
import { BrowserWindow } from 'electron';
import { loadAdBlockerEngine } from './blocker';
import { shouldUseBlocklists } from './config';
import type { ConfigType } from '../../config/dynamic';
type AdBlockOptions = ConfigType<'adblocker'>;
export default async (win: BrowserWindow, options: AdBlockOptions) => {
if (shouldUseBlocklists()) {
await loadAdBlockerEngine(
win.webContents.session,
options.cache,
options.additionalBlockLists,
options.disableDefaultLists,
);
}
};

View File

@ -0,0 +1,4 @@
export const blockers = {
WithBlocklists: 'With blocklists',
InPlayer: 'In player',
} as const;

View File

@ -0,0 +1,67 @@
// Used for caching
import path from 'node:path';
import fs, { promises } from 'node:fs';
import { ElectronBlocker } from '@cliqz/adblocker-electron';
import { app, net } from 'electron';
const SOURCES = [
'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt',
// UBlock Origin
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt',
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2020.txt',
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2021.txt',
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2022.txt',
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters-2023.txt',
// Fanboy Annoyances
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
];
export const loadAdBlockerEngine = async (
session: Electron.Session | undefined = undefined,
cache = true,
additionalBlockLists = [],
disableDefaultLists: boolean | unknown[] = false,
) => {
// Only use cache if no additional blocklists are passed
const cacheDirectory = path.join(app.getPath('userData'), 'adblock_cache');
if (!fs.existsSync(cacheDirectory)) {
fs.mkdirSync(cacheDirectory);
}
const cachingOptions
= cache && additionalBlockLists.length === 0
? {
path: path.join(cacheDirectory, 'adblocker-engine.bin'),
read: promises.readFile,
write: promises.writeFile,
}
: undefined;
const lists = [
...(
(disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) ? [] : SOURCES
),
...additionalBlockLists,
];
try {
const blocker = await ElectronBlocker.fromLists(
(url: string) => net.fetch(url),
lists,
{
// When generating the engine for caching, do not load network filters
// So that enhancing the session works as expected
// Allowing to define multiple webRequest listeners
loadNetworkFilters: session !== undefined,
},
cachingOptions,
);
if (session) {
blocker.enableBlockingInSession(session);
}
} catch (error) {
console.log('Error loading adBlocker engine', error);
}
};
export default { loadAdBlockerEngine };

View File

@ -0,0 +1,15 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { blockers } from './blocker-types';
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('adblocker', { enableFront: true });
export const shouldUseBlocklists = () => config.get('blocker') !== blockers.InPlayer;
export default Object.assign(config, {
shouldUseBlocklists,
blockers,
});

View File

@ -0,0 +1,3 @@
export default async () => {
await import('@cliqz/adblocker-electron-preload');
};

3
src/plugins/adblocker/inject.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
const inject: () => void;
export default inject;

View File

@ -0,0 +1,435 @@
/* eslint-disable */
// Source: https://addons.mozilla.org/en-US/firefox/addon/adblock-for-youtube/
// https://robwu.nl/crxviewer/?crx=https%3A%2F%2Faddons.mozilla.org%2Fen-US%2Ffirefox%2Faddon%2Fadblock-for-youtube%2F
/*
Parts of this code is derived from set-constant.js:
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
*/
module.exports = () => {
{
const pruner = function (o) {
delete o.playerAds;
delete o.adPlacements;
//
if (o.playerResponse) {
delete o.playerResponse.playerAds;
delete o.playerResponse.adPlacements;
}
//
return o;
};
JSON.parse = new Proxy(JSON.parse, {
apply() {
return pruner(Reflect.apply(...arguments));
},
});
Response.prototype.json = new Proxy(Response.prototype.json, {
apply() {
return Reflect.apply(...arguments).then((o) => pruner(o));
},
});
}
(function () {
let cValue = 'undefined';
const chain = 'playerResponse.adPlacements';
const thisScript = document.currentScript;
//
switch (cValue) {
case 'null': {
cValue = null;
break;
}
case "''": {
cValue = '';
break;
}
case 'true': {
cValue = true;
break;
}
case 'false': {
cValue = false;
break;
}
case 'undefined': {
cValue = undefined;
break;
}
case 'noopFunc': {
cValue = function () {
};
break;
}
case 'trueFunc': {
cValue = function () {
return true;
};
break;
}
case 'falseFunc': {
cValue = function () {
return false;
};
break;
}
default: {
if (/^\d+$/.test(cValue)) {
cValue = Number.parseFloat(cValue);
//
if (isNaN(cValue)) {
return;
}
if (Math.abs(cValue) > 0x7F_FF) {
return;
}
} else {
return;
}
}
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) {
return true;
}
aborted
= v !== undefined
&& v !== null
&& cValue !== undefined
&& cValue !== null
&& typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let previousGetter;
let previousSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) {
return;
}
if (odesc.get instanceof Function) {
previousGetter = odesc.get;
}
if (odesc.set instanceof Function) {
previousSetter = odesc.set;
}
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (previousGetter !== undefined) {
previousGetter();
}
//
return handler.getter();
},
set(a) {
if (previousSetter !== undefined) {
previousSetter(a);
}
//
handler.setter(a);
},
});
};
const trapChain = function (owner, chain) {
const pos = chain.indexOf('.');
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter() {
return document.currentScript === thisScript ? this.v : cValue;
},
setter(a) {
if (mustAbort(a) === false) {
return;
}
cValue = a;
},
init(v) {
if (mustAbort(v)) {
return false;
}
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === 'object' && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter() {
return this.v;
},
setter(a) {
this.v = a;
if (a instanceof Object) {
trapChain(a, chain);
}
},
init(v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
})();
(function () {
let cValue = 'undefined';
const thisScript = document.currentScript;
const chain = 'ytInitialPlayerResponse.adPlacements';
//
switch (cValue) {
case 'null': {
cValue = null;
break;
}
case "''": {
cValue = '';
break;
}
case 'true': {
cValue = true;
break;
}
case 'false': {
cValue = false;
break;
}
case 'undefined': {
cValue = undefined;
break;
}
case 'noopFunc': {
cValue = function () {
};
break;
}
case 'trueFunc': {
cValue = function () {
return true;
};
break;
}
case 'falseFunc': {
cValue = function () {
return false;
};
break;
}
default: {
if (/^\d+$/.test(cValue)) {
cValue = Number.parseFloat(cValue);
//
if (isNaN(cValue)) {
return;
}
if (Math.abs(cValue) > 0x7F_FF) {
return;
}
} else {
return;
}
}
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) {
return true;
}
aborted
= v !== undefined
&& v !== null
&& cValue !== undefined
&& cValue !== null
&& typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let previousGetter;
let previousSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) {
return;
}
if (odesc.get instanceof Function) {
previousGetter = odesc.get;
}
if (odesc.set instanceof Function) {
previousSetter = odesc.set;
}
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (previousGetter !== undefined) {
previousGetter();
}
//
return handler.getter();
},
set(a) {
if (previousSetter !== undefined) {
previousSetter(a);
}
//
handler.setter(a);
},
});
};
const trapChain = function (owner, chain) {
const pos = chain.indexOf('.');
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter() {
return document.currentScript === thisScript ? this.v : cValue;
},
setter(a) {
if (mustAbort(a) === false) {
return;
}
cValue = a;
},
init(v) {
if (mustAbort(v)) {
return false;
}
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === 'object' && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter() {
return this.v;
},
setter(a) {
this.v = a;
if (a instanceof Object) {
trapChain(a, chain);
}
},
init(v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
})();
};

View File

@ -0,0 +1,21 @@
import config from './config';
import { blockers } from './blocker-types';
import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => {
return [
{
label: 'Blocker',
submenu: Object.values(blockers).map((blocker: string) => ({
label: blocker,
type: 'radio',
checked: (config.get('blocker') || blockers.WithBlocklists) === blocker,
click() {
config.set('blocker', blocker);
},
})),
},
];
};

View File

@ -0,0 +1,15 @@
import config, { shouldUseBlocklists } from './config';
import inject from './inject';
import injectCliqzPreload from './inject-cliqz-preload';
import { blockers } from './blocker-types';
export default async () => {
if (shouldUseBlocklists()) {
// Preload adblocker to inject scripts/styles
await injectCliqzPreload();
// eslint-disable-next-line @typescript-eslint/await-thenable
} else if ((config.get('blocker')) === blockers.InPlayer) {
inject();
}
};

View File

@ -0,0 +1,9 @@
import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, style);
};

View File

@ -0,0 +1,127 @@
import { FastAverageColor } from 'fast-average-color';
import { ConfigType } from '../../config/dynamic';
function hexToHSL(H: string) {
// Convert hex to RGB first
let r = 0;
let g = 0;
let b = 0;
if (H.length == 4) {
r = Number('0x' + H[1] + H[1]);
g = Number('0x' + H[2] + H[2]);
b = Number('0x' + H[3] + H[3]);
} else if (H.length == 7) {
r = Number('0x' + H[1] + H[2]);
g = Number('0x' + H[3] + H[4]);
b = Number('0x' + H[5] + H[6]);
}
// Then to HSL
r /= 255;
g /= 255;
b /= 255;
const cmin = Math.min(r, g, b);
const cmax = Math.max(r, g, b);
const delta = cmax - cmin;
let h: number;
let s: number;
let l: number;
if (delta == 0) {
h = 0;
} else if (cmax == r) {
h = ((g - b) / delta) % 6;
} else if (cmax == g) {
h = ((b - r) / delta) + 2;
} else {
h = ((r - g) / delta) + 4;
}
h = Math.round(h * 60);
if (h < 0) {
h += 360;
}
l = (cmax + cmin) / 2;
s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1));
s = +(s * 100).toFixed(1);
l = +(l * 100).toFixed(1);
//return "hsl(" + h + "," + s + "%," + l + "%)";
return [h,s,l];
}
let hue = 0;
let saturation = 0;
let lightness = 0;
function changeElementColor(element: HTMLElement | null, hue: number, saturation: number, lightness: number){
if (element) {
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
}
export default (_: ConfigType<'album-color-theme'>) => {
// updated elements
const playerPage = document.querySelector<HTMLElement>('#player-page');
const navBarBackground = document.querySelector<HTMLElement>('#nav-bar-background');
const ytmusicPlayerBar = document.querySelector<HTMLElement>('ytmusic-player-bar');
const playerBarBackground = document.querySelector<HTMLElement>('#player-bar-background');
const sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
const sidebarSmall = document.querySelector<HTMLElement>('#mini-guide-background');
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
if (isPageOpen) {
changeElementColor(sidebarSmall, hue, saturation, lightness - 30);
} else {
if (sidebarSmall) {
sidebarSmall.style.backgroundColor = 'black';
}
}
}
}
});
if (playerPage) {
observer.observe(playerPage, { attributes: true });
}
document.addEventListener('apiLoaded', (apiEvent) => {
const fastAverageColor = new FastAverageColor();
apiEvent.detail.addEventListener('videodatachange', (name: string) => {
if (name === 'dataloaded') {
const playerResponse = apiEvent.detail.getPlayerResponse();
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
if (thumbnail) {
fastAverageColor.getColorAsync(thumbnail.url)
.then((albumColor) => {
if (albumColor) {
[hue, saturation, lightness] = hexToHSL(albumColor.hex);
changeElementColor(playerPage, hue, saturation, lightness - 30);
changeElementColor(navBarBackground, hue, saturation, lightness - 15);
changeElementColor(ytmusicPlayerBar, hue, saturation, lightness - 15);
changeElementColor(playerBarBackground, hue, saturation, lightness - 15);
changeElementColor(sidebarBig, hue, saturation, lightness - 15);
if (ytmusicAppLayout?.hasAttribute('player-page-open')) {
changeElementColor(sidebarSmall, hue, saturation, lightness - 30);
}
const ytRightClickList = document.querySelector<HTMLElement>('tp-yt-paper-listbox');
changeElementColor(ytRightClickList, hue, saturation, lightness - 15);
} else {
if (playerPage) {
playerPage.style.backgroundColor = '#000000';
}
}
})
.catch((e) => console.error(e));
}
}
});
});
};

View File

@ -0,0 +1,29 @@
yt-page-navigation-progress {
--yt-page-navigation-container-color: #00000046 !important;
--yt-page-navigation-progress-color: white !important;
}
#player-page {
transition: transform 300ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
}
#nav-bar-background {
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
}
#mini-guide-background {
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
border-right: 0px !important;
}
#guide-wrapper {
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
}
#img, #player, .song-media-controls.style-scope.ytmusic-player {
border-radius: 2% !important;
}
#items {
border-radius: 10px !important;
}

View File

@ -0,0 +1,10 @@
import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, style);
};

View File

@ -0,0 +1,138 @@
import { ConfigType } from '../../config/dynamic';
export default (_: ConfigType<'ambient-mode'>) => {
const interpolationTime = 3000; // interpolation time (ms)
const framerate = 30; // frame
const qualityRatio = 50; // width size (pixel)
let unregister: (() => void) | null = null;
const injectBlurVideo = (): (() => void) | null => {
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
const video = document.querySelector<HTMLVideoElement>('#song-video .html5-video-container > video');
const wrapper = document.querySelector('#song-video > .player-wrapper');
if (!songVideo) return null;
if (!video) return null;
if (!wrapper) return null;
const blurCanvas = document.createElement('canvas');
blurCanvas.classList.add('html5-blur-canvas');
const context = blurCanvas.getContext('2d', { willReadFrequently: true });
/* effect */
let lastEffectWorkId: number | null = null;
let lastImageData: ImageData | null = null;
const onSync = () => {
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
lastEffectWorkId = requestAnimationFrame(() => {
if (!context) return;
const width = qualityRatio;
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
if (!Number.isFinite(height)) height = width;
context.globalAlpha = 1;
if (lastImageData) {
const frameOffset = (1 / framerate) * (1000 / interpolationTime);
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1
context.putImageData(lastImageData, 0, 0);
context.globalAlpha = frameOffset;
}
context.drawImage(video, 0, 0, width, height);
const nowImageData = context.getImageData(0, 0, width, height);
lastImageData = nowImageData;
lastEffectWorkId = null;
});
};
const applyVideoAttributes = () => {
const rect = video.getBoundingClientRect();
const newWidth = Math.floor(video.width || rect.width);
const newHeight = Math.floor(video.height || rect.height);
if (newWidth === 0 || newHeight === 0) return;
blurCanvas.width = qualityRatio;
blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio);
blurCanvas.style.width = `${newWidth}px`;
blurCanvas.style.height = `${newHeight}px`;
};
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes') {
applyVideoAttributes();
}
});
});
const resizeObserver = new ResizeObserver(() => {
applyVideoAttributes();
});
/* hooking */
let canvasInterval: NodeJS.Timeout | null = null;
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / framerate)));
applyVideoAttributes();
observer.observe(songVideo, { attributes: true });
resizeObserver.observe(songVideo);
window.addEventListener('resize', applyVideoAttributes);
const onPause = () => {
if (canvasInterval) clearInterval(canvasInterval);
canvasInterval = null;
};
const onPlay = () => {
if (canvasInterval) clearInterval(canvasInterval);
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / framerate)));
};
songVideo.addEventListener('pause', onPause);
songVideo.addEventListener('play', onPlay);
/* injecting */
wrapper.prepend(blurCanvas);
/* cleanup */
return () => {
if (canvasInterval) clearInterval(canvasInterval);
songVideo.removeEventListener('pause', onPause);
songVideo.removeEventListener('play', onPlay);
observer.disconnect();
resizeObserver.disconnect();
window.removeEventListener('resize', applyVideoAttributes);
wrapper.removeChild(blurCanvas);
};
};
const playerPage = document.querySelector<HTMLElement>('#player-page');
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
if (isPageOpen) {
unregister?.();
unregister = injectBlurVideo() ?? null;
} else {
unregister?.();
unregister = null;
}
}
}
});
if (playerPage) {
observer.observe(playerPage, { attributes: true });
}
};

View File

@ -0,0 +1,7 @@
#song-video canvas.html5-blur-canvas{
position: absolute;
left: 0;
top: 0;
filter: blur(100px);
}

View File

@ -0,0 +1,17 @@
export default () =>
document.addEventListener('audioCanPlay', (e) => {
const { audioContext } = e.detail;
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
e.detail.audioSource.connect(compressor);
compressor.connect(audioContext.destination);
}, {
once: true, // Only create the audio compressor once, not on each video
passive: true,
});

View File

@ -0,0 +1,9 @@
import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, style);
};

View File

@ -0,0 +1,10 @@
#nav-bar-background,
#header.ytmusic-item-section-renderer,
ytmusic-tabs {
background: rgba(0, 0, 0, 0.3) !important;
backdrop-filter: blur(8px) !important;
}
#nav-bar-divider {
display: none !important;
}

View File

@ -0,0 +1,4 @@
export default async () => {
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
await import('simple-youtube-age-restriction-bypass');
};

View File

@ -0,0 +1,4 @@
declare module 'simple-youtube-age-restriction-bypass' {
const nothing: never;
export default nothing;
}

View File

@ -0,0 +1,19 @@
import { BrowserWindow, ipcMain } from 'electron';
import prompt from 'custom-electron-prompt';
import promptOptions from '../../providers/prompt-options';
export default (win: BrowserWindow) => {
ipcMain.handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
{
title: 'Choose Caption',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
type: 'select',
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
win,
));
};

View File

@ -0,0 +1,4 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('captions-selector', { enableFront: true });
export default config;

View File

@ -0,0 +1,101 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { ipcRenderer } from 'electron';
import configProvider from './config';
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html';
import { ElementFromHtml } from '../utils';
import { YoutubePlayer } from '../../types/youtube-player';
import type { ConfigType } from '../../config/dynamic';
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;
}
let config: ConfigType<'captions-selector'>;
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
export default async () => {
// RENDERER
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
};
function setup(api: YoutubePlayer) {
$('.right-controls-buttons').append(captionsSettingsButton);
let captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
$('video').addEventListener('srcChanged', () => {
if (config.disableCaptions) {
setTimeout(() => api.unloadModule('captions'), 100);
captionsSettingsButton.style.display = 'none';
return;
}
api.loadModule('captions');
setTimeout(() => {
captionTrackList = api.getOption('captions', 'tracklist') ?? [];
if (config.autoload && config.lastCaptionsCode) {
api.setOption('captions', 'track', {
languageCode: config.lastCaptionsCode,
});
}
captionsSettingsButton.style.display = captionTrackList?.length
? 'inline-block'
: 'none';
}, 250);
});
captionsSettingsButton.addEventListener('click', async () => {
if (captionTrackList?.length) {
const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
let currentIndex = currentCaptionTrack
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!)
: null;
const captionLabels = [
...captionTrackList.map((track) => track.displayName),
'None',
];
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
if (currentIndex === null) {
return;
}
const newCaptions = captionTrackList[currentIndex];
configProvider.set('lastCaptionsCode', newCaptions?.languageCode);
if (newCaptions) {
api.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
} else {
api.setOption('captions', 'track', {});
}
setTimeout(() => api.playVideo());
}
});
}

View File

@ -0,0 +1,22 @@
import config from './config';
import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => [
{
label: 'Automatically select last used caption',
type: 'checkbox',
checked: config.get('autoload'),
click(item) {
config.set('autoload', item.checked);
},
},
{
label: 'No captions by default',
type: 'checkbox',
checked: config.get('disableCaptions'),
click(item) {
config.set('disableCaptions', item.checked);
},
},
];

View File

@ -0,0 +1,17 @@
<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,10 @@
export default () => {
const compactSidebar = document.querySelector('#mini-guide');
const isCompactSidebarDisabled
= compactSidebar === null
|| window.getComputedStyle(compactSidebar).display === 'none';
if (isCompactSidebarDisabled) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
};

View File

@ -0,0 +1,11 @@
import { ipcMain } from 'electron';
import { Innertube } from 'youtubei.js';
export default async () => {
const yt = await Innertube.create();
ipcMain.handle('audio-url', async (_, videoID: string) => {
const info = await yt.getBasicInfo(videoID);
return info.streaming_data?.formats[0].decipher(yt.session.player);
});
};

View File

@ -0,0 +1,4 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('crossfade', { enableFront: true });
export default config;

View File

@ -0,0 +1,395 @@
/**
* VolumeFader
* Sophisticated Media Volume Fading
*
* Requires browser support for:
* - HTMLMediaElement
* - requestAnimationFrame()
* - ES6
*
* Does not depend on any third-party library.
*
* License: MIT
*
* Nick Schwarzenberg
* v0.2.0, 07/2016
*/
'use strict';
// Internal utility: check if value is a valid volume level and throw if not
const validateVolumeLevel = (value: number) => {
// Number between 0 and 1?
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
// Yup, that's fine
} else {
// Abort and throw an exception
throw new TypeError('Number between 0 and 1 expected as volume!');
}
};
type VolumeLogger = <Params extends unknown[]>(message: string, ...args: Params) => void;
interface VolumeFaderOptions {
/**
* logging `function(stuff, …)` for execution information (default: no logging)
*/
logger?: VolumeLogger;
/**
* either 'linear', 'logarithmic' or a positive number in dB (default: logarithmic)
*/
fadeScaling?: string | number;
/**
* media volume 0…1 to apply during setup (volume not touched by default)
*/
initialVolume?: number;
/**
* time in milliseconds to complete a fade (default: 1000 ms)
*/
fadeDuration?: number;
}
interface VolumeFade {
volume: {
start: number;
end: number;
};
time: {
start: number;
end: number;
};
callback?: () => void;
}
// Main class
export class VolumeFader {
private readonly media: HTMLMediaElement;
private readonly logger: VolumeLogger | false;
private scale: {
internalToVolume: (level: number) => number;
volumeToInternal: (level: number) => number;
};
private fadeDuration: number = 1000;
private active: boolean = false;
private fade: VolumeFade | undefined;
/**
* VolumeFader Constructor
*
* @param media {HTMLMediaElement} - audio or video element to be controlled
* @param options {Object} - an object with optional settings
* @throws {TypeError} if options.initialVolume or options.fadeDuration are invalid
*
*/
constructor(media: HTMLMediaElement, options: VolumeFaderOptions) {
// Passed media element of correct type?
if (media instanceof HTMLMediaElement) {
// Save reference to media element
this.media = media;
} else {
// Abort and throw an exception
throw new TypeError('Media element expected!');
}
// Make sure options is an object
options = options || {};
// Log function passed?
if (typeof options.logger === 'function') {
// Set log function to the one specified
this.logger = options.logger;
} else {
// Set log function explicitly to false
this.logger = false;
}
// Linear volume fading?
if (options.fadeScaling === 'linear') {
// Pass levels unchanged
this.scale = {
internalToVolume: (level: number) => level,
volumeToInternal: (level: number) => level,
};
// Log setting
this.logger && this.logger('Using linear fading.');
}
// No linear, but logarithmic fading…
else {
let dynamicRange: number;
// Default dynamic range?
if (
options.fadeScaling === undefined
|| options.fadeScaling === 'logarithmic'
) {
// Set default of 60 dB
dynamicRange = 3;
}
// Custom dynamic range?
else if (
typeof options.fadeScaling === 'number'
&& !Number.isNaN(options.fadeScaling)
&& options.fadeScaling > 0
) {
// Turn amplitude dB into a multiple of 10 power dB
dynamicRange = options.fadeScaling / 2 / 10;
}
// Unsupported value
else {
// Abort and throw exception
throw new TypeError(
"Expected 'linear', 'logarithmic' or a positive number as fade scaling preference!",
);
}
// Use exponential/logarithmic scaler for expansion/compression
this.scale = {
internalToVolume: (level: number) =>
this.exponentialScaler(level, dynamicRange),
volumeToInternal: (level: number) =>
this.logarithmicScaler(level, dynamicRange),
};
// Log setting if not default
options.fadeScaling
&& this.logger
&& this.logger(
'Using logarithmic fading with '
+ String(10 * dynamicRange)
+ ' dB dynamic range.',
);
}
// Set initial volume?
if (options.initialVolume !== undefined) {
// Validate volume level and throw if invalid
validateVolumeLevel(options.initialVolume);
// Set initial volume
this.media.volume = options.initialVolume;
// Log setting
this.logger
&& this.logger(
'Set initial volume to ' + String(this.media.volume) + '.',
);
}
// Fade duration given?
if (options.fadeDuration === undefined) {
// Set default fade duration (1000 ms)
this.fadeDuration = 1000;
} else {
// Try to set given fade duration (will log if successful and throw if not)
this.setFadeDuration(options.fadeDuration);
}
// Indicate that fader is not active yet
this.active = false;
// Initialization done
this.logger && this.logger('Initialized for', this.media);
}
/**
* Re(start) the update cycle.
* (this.active must be truthy for volume updates to take effect)
*
* @return {Object} VolumeFader instance for chaining
*/
start() {
// Set fader to be active
this.active = true;
// Start by running the update method
this.updateVolume();
// Return instance for chaining
return this;
}
/**
* Stop the update cycle.
* (interrupting any fade)
*
* @return {Object} VolumeFader instance for chaining
*/
stop() {
// Set fader to be inactive
this.active = false;
// Return instance for chaining
return this;
}
/**
* Set fade duration.
* (used for future calls to fadeTo)
*
* @param {Number} fadeDuration - fading length in milliseconds
* @throws {TypeError} if fadeDuration is not a number greater than zero
* @return {Object} VolumeFader instance for chaining
*/
setFadeDuration(fadeDuration: number) {
// If duration is a valid number > 0…
if (!Number.isNaN(fadeDuration) && fadeDuration > 0) {
// Set fade duration
this.fadeDuration = fadeDuration;
// Log setting
this.logger
&& this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
} else {
// Abort and throw an exception
throw new TypeError('Positive number expected as fade duration!');
}
// Return instance for chaining
return this;
}
/**
* Define a new fade and start fading.
*
* @param {Number} targetVolume - level to fade to in the range 0…1
* @param {Function} callback - (optional) function to be called when fade is complete
* @throws {TypeError} if targetVolume is not in the range 0…1
* @return {Object} VolumeFader instance for chaining
*/
fadeTo(targetVolume: number, callback?: () => void) {
// Validate volume and throw if invalid
validateVolumeLevel(targetVolume);
// Define new fade
this.fade = {
// Volume start and end point on internal fading scale
volume: {
start: this.scale.volumeToInternal(this.media.volume),
end: this.scale.volumeToInternal(targetVolume),
},
// Time start and end point
time: {
start: Date.now(),
end: Date.now() + this.fadeDuration,
},
// Optional callback function
callback,
};
// Start fading
this.start();
// Log new fade
this.logger && this.logger('New fade started:', this.fade);
// Return instance for chaining
return this;
}
// Convenience shorthand methods for common fades
fadeIn(callback: () => void) {
this.fadeTo(1, callback);
}
fadeOut(callback: () => void) {
this.fadeTo(0, callback);
}
/**
* Internal: Update media volume.
* (calls itself through requestAnimationFrame)
*/
updateVolume() {
// Fader active and fade available to process?
if (this.active && this.fade) {
// Get current time
const now = Date.now();
// Time left for fading?
if (now < this.fade.time.end) {
// Compute current fade progress
const progress
= (now - this.fade.time.start)
/ (this.fade.time.end - this.fade.time.start);
// Compute current level on internal scale
const level
= (progress * (this.fade.volume.end - this.fade.volume.start)) + this.fade.volume.start;
// Map fade level to volume level and apply it to media element
this.media.volume = this.scale.internalToVolume(level);
// Schedule next update
window.requestAnimationFrame(this.updateVolume.bind(this));
} else {
// Log end of fade
this.logger
&& this.logger(
'Fade to ' + String(this.fade.volume.end) + ' complete.',
);
// Time is up, jump to target volume
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
// Set fader to be inactive
this.active = false;
// Done, call back (if callable)
typeof this.fade.callback === 'function' && this.fade.callback();
// Clear fade
this.fade = undefined;
}
}
}
/**
* Internal: Exponential scaler with dynamic range limit.
*
* @param {Number} input - logarithmic input level to be expanded (float, 0…1)
* @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0…∞)
* @return {Number} - expanded level (float, 0…1)
*/
exponentialScaler(input: number, dynamicRange: number) {
// Special case: make zero (or any falsy input) return zero
if (input === 0) {
// Since the dynamic range is limited,
// allow a zero to produce a plain zero instead of a small faction
// (audio would not be recognized as silent otherwise)
return 0;
}
// Scale 0…1 to minus something × 10 dB
input = (input - 1) * dynamicRange;
// Compute power of 10
return 10 ** input;
}
/**
* Internal: Logarithmic scaler with dynamic range limit.
*
* @param {Number} input - exponential input level to be compressed (float, 0…1)
* @param {Number} dynamicRange - coerced input range, in multiples of 10 dB (float, 0…∞)
* @return {Number} - compressed level (float, 0…1)
*/
logarithmicScaler(input: number, dynamicRange: number) {
// Special case: make zero (or any falsy input) return zero
if (input === 0) {
// Logarithm of zero would be -∞, which would map to zero anyway
return 0;
}
// Compute base-10 logarithm
input = Math.log10(input);
// Scale minus something × 10 dB to 0…1 (clipping at 0)
return Math.max(1 + (input / dynamicRange), 0);
}
}
export default {
VolumeFader
};

View File

@ -0,0 +1,164 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { ipcRenderer } from 'electron';
import { Howl } from 'howler';
// Extracted from https://github.com/bitfasching/VolumeFader
import { VolumeFader } from './fader';
import configProvider from './config';
import defaultConfigs from '../../config/defaults';
import type { ConfigType } from '../../config/dynamic';
let transitionAudio: Howl; // Howler audio used to fade out the current music
let firstVideo = true;
let waitForTransition: Promise<unknown>;
const defaultConfig = defaultConfigs.plugins.crossfade;
let config: ConfigType<'crossfade'>;
const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(config[key]) || (defaultConfig[key] as number);
const getStreamURL = async (videoID: string) => ipcRenderer.invoke('audio-url', videoID) as Promise<string>;
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
const watchVideoIDChanges = (cb: (id: string) => void) => {
window.navigation.addEventListener('navigate', (event) => {
const currentVideoID = getVideoIDFromURL(
(event.currentTarget as Navigation).currentEntry?.url ?? '',
);
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
if (
nextVideoID
&& currentVideoID
&& (firstVideo || nextVideoID !== currentVideoID)
) {
if (isReadyToCrossfade()) {
crossfade(() => {
cb(nextVideoID);
});
} else {
cb(nextVideoID);
firstVideo = false;
}
}
});
};
const createAudioForCrossfade = (url: string) => {
if (transitionAudio) {
transitionAudio.unload();
}
transitionAudio = new Howl({
src: url,
html5: true,
volume: 0,
});
syncVideoWithTransitionAudio();
};
const syncVideoWithTransitionAudio = () => {
const video = document.querySelector('video')!;
const videoFader = new VolumeFader(video, {
fadeScaling: configGetNumber('fadeScaling'),
fadeDuration: configGetNumber('fadeInDuration'),
});
transitionAudio.play();
transitionAudio.seek(video.currentTime);
video.addEventListener('seeking', () => {
transitionAudio.seek(video.currentTime);
});
video.addEventListener('pause', () => {
transitionAudio.pause();
});
video.addEventListener('play', () => {
transitionAudio.play();
transitionAudio.seek(video.currentTime);
// Fade in
const videoVolume = video.volume;
video.volume = 0;
videoFader.fadeTo(videoVolume);
});
// Exit just before the end for the transition
const transitionBeforeEnd = () => {
if (
video.currentTime >= video.duration - configGetNumber('secondsBeforeEnd')
&& isReadyToCrossfade()
) {
video.removeEventListener('timeupdate', transitionBeforeEnd);
// Go to next video - XXX: does not support "repeat 1" mode
document.querySelector<HTMLButtonElement>('.next-button')?.click();
}
};
video.addEventListener('timeupdate', transitionBeforeEnd);
};
const onApiLoaded = () => {
watchVideoIDChanges(async (videoID) => {
await waitForTransition;
const url = await getStreamURL(videoID);
if (!url) {
return;
}
await createAudioForCrossfade(url);
});
};
const crossfade = (cb: () => void) => {
if (!isReadyToCrossfade()) {
cb();
return;
}
let resolveTransition: () => void;
waitForTransition = new Promise<void>((resolve) => {
resolveTransition = resolve;
});
const video = document.querySelector('video')!;
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume,
fadeScaling: configGetNumber('fadeScaling'),
fadeDuration: configGetNumber('fadeOutDuration'),
});
// Fade out the music
video.volume = 0;
fader.fadeOut(() => {
resolveTransition();
cb();
});
};
export default async () => {
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
});
document.addEventListener('apiLoaded', onApiLoaded, {
once: true,
passive: true,
});
};

View File

@ -0,0 +1,86 @@
import prompt from 'custom-electron-prompt';
import { BrowserWindow } from 'electron';
import config from './config';
import promptOptions from '../../providers/prompt-options';
import configOptions from '../../config/defaults';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
const defaultOptions = configOptions.plugins.crossfade;
export default (win: BrowserWindow): MenuTemplate => [
{
label: 'Advanced',
async click() {
const newOptions = await promptCrossfadeValues(win, config.getAll());
if (newOptions) {
config.setAll(newOptions);
}
},
},
];
async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'crossfade'>): Promise<Partial<ConfigType<'crossfade'>> | undefined> {
const res = await prompt(
{
title: 'Crossfade Options',
type: 'multiInput',
multiInputOptions: [
{
label: 'Fade in duration (ms)',
value: options.fadeInDuration || defaultOptions.fadeInDuration,
inputAttrs: {
type: 'number',
required: true,
min: '0',
step: '100',
},
},
{
label: 'Fade out duration (ms)',
value: options.fadeOutDuration || defaultOptions.fadeOutDuration,
inputAttrs: {
type: 'number',
required: true,
min: '0',
step: '100',
},
},
{
label: 'Crossfade x seconds before end',
value:
options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd,
inputAttrs: {
type: 'number',
required: true,
min: '0',
},
},
{
label: 'Fade scaling',
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
value: options.fadeScaling || defaultOptions.fadeScaling,
},
],
resizable: true,
height: 360,
...promptOptions(),
},
win,
).catch(console.error);
if (!res) {
return undefined;
}
return {
fadeInDuration: Number(res[0]),
fadeOutDuration: Number(res[1]),
secondsBeforeEnd: Number(res[2]),
fadeScaling: res[3],
};
}

View File

@ -0,0 +1,23 @@
import type { ConfigType } from '../../config/dynamic';
export default (options: ConfigType<'disable-autoplay'>) => {
const timeUpdateListener = (e: Event) => {
if (e.target instanceof HTMLVideoElement) {
e.target.pause();
}
};
document.addEventListener('apiLoaded', (apiEvent) => {
const eventListener = (name: string) => {
if (options.applyOnce) {
apiEvent.detail.removeEventListener('videodatachange', eventListener);
}
if (name === 'dataloaded') {
apiEvent.detail.pauseVideo();
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
}
};
apiEvent.detail.addEventListener('videodatachange', eventListener);
}, { once: true, passive: true });
};

View File

@ -0,0 +1,20 @@
import { BrowserWindow } from 'electron';
import { setMenuOptions } from '../../config/plugins';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
export default (_: BrowserWindow, options: ConfigType<'disable-autoplay'>): MenuTemplate => [
{
label: 'Applies only on startup',
type: 'checkbox',
checked: options.applyOnce,
click() {
setMenuOptions('disable-autoplay', {
applyOnce: !options.applyOnce,
});
}
}
];

228
src/plugins/discord/back.ts Normal file
View File

@ -0,0 +1,228 @@
import { app, dialog, ipcMain } from 'electron';
import { Client as DiscordClient } from '@xhayper/discord-rpc';
import { dev } from 'electron-is';
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
import type { ConfigType } from '../../config/dynamic';
// Application ID registered by @Zo-Bro-23
const clientId = '1043858434585526382';
export interface Info {
rpc: DiscordClient;
ready: boolean;
autoReconnect: boolean;
lastSongInfo?: SongInfo;
}
const info: Info = {
rpc: new DiscordClient({
clientId,
}),
ready: false,
autoReconnect: true,
lastSongInfo: undefined,
};
/**
* @type {(() => void)[]}
*/
const refreshCallbacks: (() => void)[] = [];
const resetInfo = () => {
info.ready = false;
clearTimeout(clearActivity);
if (dev()) {
console.log('discord disconnected');
}
for (const cb of refreshCallbacks) {
cb();
}
};
const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => {
if (!info.autoReconnect || info.rpc.isConnected) {
return;
}
info.rpc.login().then(resolve).catch(reject);
}, 5000));
const connectRecursive = () => {
if (!info.autoReconnect || info.rpc.isConnected) {
return;
}
connectTimeout().catch(connectRecursive);
};
let window: Electron.BrowserWindow;
export const connect = (showError = false) => {
if (info.rpc.isConnected) {
if (dev()) {
console.log('Attempted to connect with active connection');
}
return;
}
info.ready = false;
// Startup the rpc client
info.rpc.login().catch((error: Error) => {
resetInfo();
if (dev()) {
console.error(error);
}
if (info.autoReconnect) {
connectRecursive();
} else if (showError) {
dialog.showMessageBox(window, {
title: 'Connection failed',
message: error.message || String(error),
type: 'error',
});
}
});
};
let clearActivity: NodeJS.Timeout | undefined;
let updateActivity: SongInfoCallback;
type DiscordOptions = ConfigType<'discord'>;
export default (
win: Electron.BrowserWindow,
options: DiscordOptions,
) => {
info.rpc.on('connected', () => {
if (dev()) {
console.log('discord connected');
}
for (const cb of refreshCallbacks) {
cb();
}
});
info.rpc.on('ready', () => {
info.ready = true;
if (info.lastSongInfo) {
updateActivity(info.lastSongInfo);
}
});
info.rpc.on('disconnected', () => {
resetInfo();
if (info.autoReconnect) {
connectTimeout();
}
});
info.autoReconnect = options.autoReconnect;
window = win;
// We get multiple events
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
// Skip time: PAUSE(N), PLAY(N)
updateActivity = (songInfo) => {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
return;
}
info.lastSongInfo = songInfo;
// Stop the clear activity timout
clearTimeout(clearActivity);
// Stop early if discord connection is not ready
// do this after clearTimeout to avoid unexpected clears
if (!info.rpc || !info.ready) {
return;
}
// Clear directly if timeout is 0
if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) {
info.rpc.user?.clearActivity().catch(console.error);
return;
}
// Song information changed, so lets update the rich presence
// @see https://discord.com/developers/docs/topics/gateway#activity-object
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character
if (songInfo.title.length < 2) {
songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
}
if (songInfo.artist.length < 2) {
songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
}
const activityInfo: SetActivity = {
details: songInfo.title,
state: songInfo.artist,
largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album ?? '',
buttons: [
...(options.listenAlong ? [{ label: 'Listen Along', url: songInfo.url ?? '' }] : []),
...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]),
],
};
if (songInfo.isPaused) {
// Add a paused icon to show that the song is paused
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
// Set start the timer so the activity gets cleared after a while if enabled
if (options.activityTimoutEnabled) {
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000);
}
} else if (!options.hideDurationLeft) {
// Add the start and end time of the song
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp
= songStartTime + (songInfo.songDuration * 1000);
}
info.rpc.user?.setActivity(activityInfo).catch(console.error);
};
// If the page is ready, register the callback
win.once('ready-to-show', () => {
let lastSongInfo: SongInfo;
registerCallback((songInfo) => {
lastSongInfo = songInfo;
updateActivity(songInfo);
});
connect();
let lastSent = Date.now();
ipcMain.on('timeChanged', (_, t: number) => {
const currentTime = Date.now();
// if lastSent is more than 5 seconds ago, send the new time
if (currentTime - lastSent > 5000) {
lastSent = currentTime;
lastSongInfo.elapsedSeconds = t;
updateActivity(lastSongInfo);
}
});
});
app.on('window-all-closed', clear);
};
export const clear = () => {
if (info.rpc) {
info.rpc.user?.clearActivity();
}
clearTimeout(clearActivity);
};
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
export const isConnected = () => info.rpc !== null;

View File

@ -0,0 +1,98 @@
import prompt from 'custom-electron-prompt';
import { clear, connect, isConnected, registerRefresh } from './back';
import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options';
import { singleton } from '../../providers/decorators';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
registerRefresh(refreshMenu);
});
type DiscordOptions = ConfigType<'discord'>;
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => {
registerRefreshOnce(refreshMenu);
return [
{
label: isConnected() ? 'Connected' : 'Reconnect',
enabled: !isConnected(),
click: () => connect(),
},
{
label: 'Auto reconnect',
type: 'checkbox',
checked: options.autoReconnect,
click(item: Electron.MenuItem) {
options.autoReconnect = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Clear activity',
click: clear,
},
{
label: 'Clear activity after timeout',
type: 'checkbox',
checked: options.activityTimoutEnabled,
click(item: Electron.MenuItem) {
options.activityTimoutEnabled = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Listen Along',
type: 'checkbox',
checked: options.listenAlong,
click(item: Electron.MenuItem) {
options.listenAlong = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Hide GitHub link Button',
type: 'checkbox',
checked: options.hideGitHubButton,
click(item: Electron.MenuItem) {
options.hideGitHubButton = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Hide duration left',
type: 'checkbox',
checked: options.hideDurationLeft,
click(item: Electron.MenuItem) {
options.hideDurationLeft = item.checked;
setMenuOptions('discord', options);
},
},
{
label: 'Set inactivity timeout',
click: () => setInactivityTimeout(win, options),
},
];
};
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
const output = await prompt({
title: 'Set Inactivity Timeout',
label: 'Enter inactivity timeout in seconds:',
value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)),
type: 'counter',
counterOptions: { minimum: 0, multiFire: true },
width: 450,
...promptOptions(),
}, win);
if (output) {
options.activityTimoutTime = Math.round(~~output * 1e3);
setMenuOptions('discord', options);
}
}

View File

@ -0,0 +1,634 @@
import {
createWriteStream,
existsSync,
mkdirSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
import {
ClientType,
Innertube,
UniversalCache,
Utils,
YTNodes,
} from 'youtubei.js';
import is from 'electron-is';
import filenamify from 'filenamify';
import { Mutex } from 'async-mutex';
import { createFFmpeg } from '@ffmpeg.wasm/main';
import NodeID3, { TagConstants } from 'node-id3';
import {
cropMaxWidth,
getFolder,
sendFeedback as sendFeedback_,
setBadge,
} from './utils';
import config from './config';
import { YoutubeFormatList, type Preset, DefaultPresetList } from './types';
import style from './style.css';
import { fetchFromGenius } from '../lyrics-genius/back';
import { isEnabled } from '../../config/plugins';
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
import { injectCSS } from '../utils';
import { cache } from '../../providers/decorators';
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic';
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
import type { GetPlayerResponse } from '../../types/get-player-response';
type CustomSongInfo = SongInfo & { trackId?: string };
const ffmpeg = createFFmpeg({
log: false,
logger() {}, // Console.log,
progress() {}, // Console.log,
});
const ffmpegMutex = new Mutex();
let yt: Innertube;
let win: BrowserWindow;
let playingUrl: string;
const sendError = (error: Error, source?: string) => {
win.setProgressBar(-1); // Close progress bar
setBadge(0); // Close badge
sendFeedback_(win); // Reset feedback
const songNameMessage = source ? `\nin ${source}` : '';
const cause = error.cause ? `\n\n${String(error.cause)}` : '';
const message = `${error.toString()}${songNameMessage}${cause}`;
console.error(message, error, error?.stack);
dialog.showMessageBox({
type: 'info',
buttons: ['OK'],
title: 'Error in download!',
message: 'Argh! Apologies, download failed…',
detail: message,
});
};
export const getCookieFromWindow = async (win: BrowserWindow) => {
return (
await win.webContents.session.cookies.get({
url: 'https://music.youtube.com',
})
)
.map((it) => it.name + '=' + it.value + ';')
.join('');
};
export default async (win_: BrowserWindow) => {
win = win_;
injectCSS(win.webContents, style);
yt = await Innertube.create({
cache: new UniversalCache(false),
cookie: await getCookieFromWindow(win),
generate_session_locally: true,
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
if (init?.body && !init.method) {
init.method = 'POST';
}
const request = new Request(
url,
input instanceof Request ? input : undefined,
);
return net.fetch(request, init);
}) as typeof fetch,
});
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
});
ipcMain.on('download-playlist-request', async (_event, url: string) =>
downloadPlaylist(url),
);
};
export async function downloadSong(
url: string,
playlistFolder: string | undefined = undefined,
trackId: string | undefined = undefined,
increasePlaylistProgress: (value: number) => void = () => {},
) {
let resolvedName;
try {
await downloadSongUnsafe(
false,
url,
(name: string) => (resolvedName = name),
playlistFolder,
trackId,
increasePlaylistProgress,
);
} catch (error: unknown) {
sendError(error as Error, resolvedName || url);
}
}
export async function downloadSongFromId(
id: string,
playlistFolder: string | undefined = undefined,
trackId: string | undefined = undefined,
increasePlaylistProgress: (value: number) => void = () => {},
) {
let resolvedName;
try {
await downloadSongUnsafe(
true,
id,
(name: string) => (resolvedName = name),
playlistFolder,
trackId,
increasePlaylistProgress,
);
} catch (error: unknown) {
sendError(error as Error, resolvedName || id);
}
}
async function downloadSongUnsafe(
isId: boolean,
idOrUrl: string,
setName: (name: string) => void,
playlistFolder: string | undefined = undefined,
trackId: string | undefined = undefined,
increasePlaylistProgress: (value: number) => void = () => {},
) {
const sendFeedback = (message: unknown, progress?: number) => {
if (!playlistFolder) {
sendFeedback_(win, message);
if (progress && !isNaN(progress)) {
win.setProgressBar(progress);
}
}
};
sendFeedback('Downloading...', 2);
let id: string | null;
if (isId) {
id = idOrUrl;
} else {
id = getVideoId(idOrUrl);
if (typeof id !== 'string') throw new Error('Video not found');
}
let info: TrackInfo | VideoInfo = await yt.music.getInfo(id);
if (!info) {
throw new Error('Video not found');
}
const metadata = getMetadata(info);
if (metadata.album === 'N/A') {
metadata.album = '';
}
metadata.trackId = trackId;
const dir =
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
metadata.title
}`;
setName(name);
let playabilityStatus = info.playability_status;
let bypassedResult = null;
if (playabilityStatus.status === 'LOGIN_REQUIRED') {
// Try to bypass the age restriction
bypassedResult = await getAndroidTvInfo(id);
playabilityStatus = bypassedResult.playability_status;
if (playabilityStatus.status === 'LOGIN_REQUIRED') {
throw new Error(
`[${playabilityStatus.status}] ${playabilityStatus.reason}`,
);
}
info = bypassedResult;
}
if (playabilityStatus.status === 'UNPLAYABLE') {
const errorScreen =
playabilityStatus.error_screen as PlayerErrorMessage | null;
throw new Error(
`[${playabilityStatus.status}] ${errorScreen?.reason.text}: ${errorScreen?.subreason.text}`,
);
}
const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)';
let presetSetting: Preset;
if (selectedPreset === 'Custom') {
presetSetting =
config.get('customPresetSetting') ?? DefaultPresetList['Custom'];
} else if (selectedPreset === 'Source') {
presetSetting = DefaultPresetList['Source'];
} else {
presetSetting = DefaultPresetList['mp3 (256kbps)'];
}
const downloadOptions: FormatOptions = {
type: 'audio', // Audio, video or video+audio
quality: 'best', // Best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'any', // Media container format
};
const format = info.chooseFormat(downloadOptions);
let targetFileExtension: string;
if (!presetSetting?.extension) {
targetFileExtension =
YoutubeFormatList.find((it) => it.itag === format.itag)?.container ??
'mp3';
} else {
targetFileExtension = presetSetting?.extension ?? 'mp3';
}
let filename = filenamify(`${name}.${targetFileExtension}`, {
replacement: '_',
maxLength: 255,
});
if (!is.macOS()) {
filename = filename.normalize('NFC');
}
const filePath = join(dir, filename);
if (config.get('skipExisting') && existsSync(filePath)) {
sendFeedback(null, -1);
return;
}
const stream = await info.download(downloadOptions);
console.info(
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.videoId}]`,
);
const iterableStream = Utils.streamToIterable(stream);
if (!existsSync(dir)) {
mkdirSync(dir);
}
const fileBuffer = await iterableStreamToTargetFile(
iterableStream,
targetFileExtension,
metadata,
presetSetting?.ffmpegArgs ?? [],
format.content_length ?? 0,
sendFeedback,
increasePlaylistProgress,
);
if (fileBuffer) {
if (targetFileExtension !== 'mp3') {
createWriteStream(filePath).write(fileBuffer);
} else {
const buffer = await writeID3(
Buffer.from(fileBuffer),
metadata,
sendFeedback,
);
if (buffer) {
writeFileSync(filePath, buffer);
}
}
}
sendFeedback(null, -1);
console.info(`Done: "${filePath}"`);
}
async function iterableStreamToTargetFile(
stream: AsyncGenerator<Uint8Array, void>,
extension: string,
metadata: CustomSongInfo,
presetFfmpegArgs: string[],
contentLength: number,
sendFeedback: (str: string, value?: number) => void,
increasePlaylistProgress: (value: number) => void = () => {},
) {
const chunks = [];
let downloaded = 0;
for await (const chunk of stream) {
downloaded += chunk.length;
chunks.push(chunk);
const ratio = downloaded / contentLength;
const progress = Math.floor(ratio * 100);
sendFeedback(`Download: ${progress}%`, ratio);
// 15% for download, 85% for conversion
// This is a very rough estimate, trying to make the progress bar look nice
increasePlaylistProgress(ratio * 0.15);
}
sendFeedback('Loading…', 2); // Indefinite progress bar after download
const buffer = Buffer.concat(chunks);
const safeVideoName = randomBytes(32).toString('hex');
const releaseFFmpegMutex = await ffmpegMutex.acquire();
try {
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
sendFeedback('Preparing file…');
ffmpeg.FS('writeFile', safeVideoName, buffer);
sendFeedback('Converting…');
ffmpeg.setProgress(({ ratio }) => {
sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio);
increasePlaylistProgress(0.15 + ratio * 0.85);
});
const safeVideoNameWithExtension = `${safeVideoName}.${extension}`;
try {
await ffmpeg.run(
'-i',
safeVideoName,
...presetFfmpegArgs,
...getFFmpegMetadataArgs(metadata),
safeVideoNameWithExtension,
);
} finally {
ffmpeg.FS('unlink', safeVideoName);
}
sendFeedback('Saving…');
try {
return ffmpeg.FS('readFile', safeVideoNameWithExtension);
} finally {
ffmpeg.FS('unlink', safeVideoNameWithExtension);
}
} catch (error: unknown) {
sendError(error as Error, safeVideoName);
} finally {
releaseFFmpegMutex();
}
}
const getCoverBuffer = cache(async (url: string) => {
const nativeImage = cropMaxWidth(await getImage(url));
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
});
async function writeID3(
buffer: Buffer,
metadata: CustomSongInfo,
sendFeedback: (str: string, value?: number) => void,
) {
try {
sendFeedback('Writing ID3 tags...');
const tags: NodeID3.Tags = {};
// Create the metadata tags
tags.title = metadata.title;
tags.artist = metadata.artist;
if (metadata.album) {
tags.album = metadata.album;
}
const coverBuffer = await getCoverBuffer(metadata.imageSrc ?? '');
if (coverBuffer) {
tags.image = {
mime: 'image/png',
type: {
id: TagConstants.AttachedPicture.PictureType.FRONT_COVER,
},
description: 'thumbnail',
imageBuffer: coverBuffer,
};
}
if (isEnabled('lyrics-genius')) {
const lyrics = await fetchFromGenius(metadata);
if (lyrics) {
tags.unsynchronisedLyrics = {
language: '',
text: lyrics,
};
}
}
if (metadata.trackId) {
tags.trackNumber = metadata.trackId;
}
return NodeID3.write(tags, buffer);
} catch (error: unknown) {
sendError(error as Error, `${metadata.artist} - ${metadata.title}`);
return null;
}
}
export async function downloadPlaylist(givenUrl?: string | URL) {
try {
givenUrl = new URL(givenUrl ?? '');
} catch {
return;
}
const playlistId =
getPlaylistID(givenUrl) ||
getPlaylistID(new URL(win.webContents.getURL())) ||
getPlaylistID(new URL(playingUrl));
if (!playlistId) {
sendError(new Error('No playlist ID found'));
return;
}
const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
console.log(`trying to get playlist ID: '${playlistId}'`);
sendFeedback('Getting playlist info…');
let playlist: Playlist;
try {
playlist = await yt.music.getPlaylist(playlistId);
} catch (error: unknown) {
sendError(
Error(
`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(
error,
)}`,
),
);
return;
}
if (!playlist || !playlist.items || playlist.items.length === 0) {
sendError(new Error('Playlist is empty'));
}
const items = playlist.items!.as(YTNodes.MusicResponsiveListItem);
if (items.length === 1) {
sendFeedback('Playlist has only one item, downloading it directly');
await downloadSongFromId(items.at(0)!.id!);
return;
}
const normalPlaylistTitle = playlist.header?.title?.text;
const playlistTitle =
normalPlaylistTitle ??
playlist.page.contents_memo
?.get('MusicResponsiveListItemFlexColumn')
?.at(2)
?.as(YTNodes.MusicResponsiveListItemFlexColumn)?.title?.text ??
'NO_TITLE';
const isAlbum = !normalPlaylistTitle;
let safePlaylistTitle = filenamify(playlistTitle, { replacement: ' ' });
if (!is.macOS()) {
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
}
const folder = getFolder(config.get('downloadFolder') ?? '');
const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) {
if (!config.get('skipExisting')) {
sendError(new Error(`The folder ${playlistFolder} already exists`));
return;
}
} else {
mkdirSync(playlistFolder, { recursive: true });
}
dialog.showMessageBox({
type: 'info',
buttons: ['OK'],
title: 'Started Download',
message: `Downloading Playlist "${playlistTitle}"`,
detail: `(${items.length} songs)`,
});
if (is.dev()) {
console.log(
`Downloading playlist "${playlistTitle}" - ${items.length} songs (${playlistId})`,
);
}
win.setProgressBar(2); // Starts with indefinite bar
setBadge(items.length);
let counter = 1;
const progressStep = 1 / items.length;
const increaseProgress = (itemPercentage: number) => {
const currentProgress = (counter - 1) / (items.length ?? 1);
const newProgress = currentProgress + progressStep * itemPercentage;
win.setProgressBar(newProgress);
};
try {
for (const song of items) {
sendFeedback(`Downloading ${counter}/${items.length}...`);
const trackId = isAlbum ? counter : undefined;
await downloadSongFromId(
song.id!,
playlistFolder,
trackId?.toString(),
increaseProgress,
).catch((error) =>
sendError(
new Error(
`Error downloading "${
song.author!.name
} - ${song.title!}":\n ${error}`,
),
),
);
win.setProgressBar(counter / items.length);
setBadge(items.length - counter);
counter++;
}
} catch (error: unknown) {
sendError(error as Error);
} finally {
win.setProgressBar(-1); // Close progress bar
setBadge(0); // Close badge counter
sendFeedback(); // Clear feedback
}
}
function getFFmpegMetadataArgs(metadata: CustomSongInfo) {
if (!metadata) {
return [];
}
return [
...(metadata.title ? ['-metadata', `title=${metadata.title}`] : []),
...(metadata.artist ? ['-metadata', `artist=${metadata.artist}`] : []),
...(metadata.album ? ['-metadata', `album=${metadata.album}`] : []),
...(metadata.trackId ? ['-metadata', `track=${metadata.trackId}`] : []),
];
}
// Playlist radio modifier needs to be cut from playlist ID
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
const getPlaylistID = (aURL: URL) => {
const result =
aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
return result.slice(INVALID_PLAYLIST_MODIFIER.length);
}
return result;
};
const getVideoId = (url: URL | string): string | null => {
return new URL(url).searchParams.get('v');
};
const getMetadata = (info: TrackInfo): CustomSongInfo => ({
videoId: info.basic_info.id!,
title: cleanupName(info.basic_info.title!),
artist: cleanupName(info.basic_info.author!),
album: info.player_overlays?.browser_media_session?.as(
YTNodes.BrowserMediaSession,
).album?.text,
imageSrc: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))
?.url,
views: info.basic_info.view_count!,
songDuration: info.basic_info.duration!,
});
// This is used to bypass age restrictions
const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
const innertube = await Innertube.create({
client_type: ClientType.TV_EMBEDDED,
generate_session_locally: true,
retrieve_player: true,
});
// GetInfo 404s with the bypass, so we use getBasicInfo instead
// that's fine as we only need the streaming data
return await innertube.getBasicInfo(id, 'TV_EMBEDDED');
};

View File

@ -0,0 +1,4 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('downloader');
export default config;

View File

@ -0,0 +1,83 @@
import { ipcRenderer } from 'electron';
import downloadHTML from './templates/download.html';
import defaultConfig from '../../config/defaults';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils';
import { getSongInfo } from '../../providers/song-info-front';
let menu: Element | null = null;
let progress: Element | null = null;
const downloadButton = ElementFromHtml(downloadHTML);
let doneFirstLoad = false;
const menuObserver = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) {
return;
}
}
if (menu.contains(downloadButton)) {
return;
}
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
return;
}
menu.prepend(downloadButton);
progress = document.querySelector('#ytmcustom-download');
if (doneFirstLoad) {
return;
}
setTimeout(() => doneFirstLoad ||= true, 500);
});
// TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld("downloader", {
// download: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
(global as any).download = () => {
let videoUrl = getSongMenu()
// Selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
?.getAttribute('href');
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = defaultConfig.url + '/' + videoUrl;
}
if (videoUrl.includes('?playlist=')) {
ipcRenderer.send('download-playlist-request', videoUrl);
return;
}
} else {
videoUrl = getSongInfo().url || window.location.href;
}
ipcRenderer.send('download-song', videoUrl);
};
export default () => {
document.addEventListener('apiLoaded', () => {
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true,
subtree: true,
});
}, { once: true, passive: true });
ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
if (progress) {
progress.innerHTML = feedback || 'Download';
} else {
console.warn('Cannot update progress');
}
});
};

View File

@ -0,0 +1,46 @@
import { dialog } from 'electron';
import { downloadPlaylist } from './back';
import { defaultMenuDownloadLabel, getFolder } from './utils';
import { DefaultPresetList } from './types';
import config from './config';
import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => [
{
label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(),
},
{
label: 'Choose download folder',
click() {
const result = dialog.showOpenDialogSync({
properties: ['openDirectory', 'createDirectory'],
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
});
if (result) {
config.set('downloadFolder', result[0]);
} // Else = user pressed cancel
},
},
{
label: 'Presets',
submenu: Object.keys(DefaultPresetList).map((preset) => ({
label: preset,
type: 'radio',
checked: config.get('selectedPreset') === preset,
click() {
config.set('selectedPreset', preset);
},
})),
},
{
label: 'Skip existing files',
type: 'checkbox',
checked: config.get('skipExisting'),
click(item) {
config.set('skipExisting', item.checked);
},
},
];

View File

@ -0,0 +1,21 @@
.menu-item {
display: var(--ytmusic-menu-item_-_display);
height: var(--ytmusic-menu-item_-_height);
align-items: var(--ytmusic-menu-item_-_align-items);
padding: var(--ytmusic-menu-item_-_padding);
cursor: pointer;
}
.menu-item > .yt-simple-endpoint:hover {
background-color: var(--ytmusic-menu-item-hover-background-color);
}
.menu-icon {
flex: var(--ytmusic-menu-item-icon_-_flex);
margin: var(--ytmusic-menu-item-icon_-_margin);
fill: var(--ytmusic-menu-item-icon_-_fill);
stroke: var(--iron-icon-stroke-color, none);
width: var(--iron-icon-width, 24px);
height: var(--iron-icon-height, 24px);
animation: var(--iron-icon_-_animation);
}

View File

@ -0,0 +1,45 @@
<div
aria-disabled="false"
aria-selected="false"
class="style-scope menu-item ytmusic-menu-popup-renderer"
onclick="download()"
role="option"
tabindex="-1"
>
<div
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
id="navigation-endpoint"
tabindex="-1"
>
<div
class="icon menu-icon style-scope ytmusic-menu-navigation-item-renderer"
>
<svg
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="M25.462,19.105v6.848H4.515v-6.848H0.489v8.861c0,1.111,0.9,2.012,2.016,2.012h24.967c1.115,0,2.016-0.9,2.016-2.012v-8.861H25.462z"
fill="#aaaaaa"
/>
<path
class="style-scope yt-icon"
d="M14.62,18.426l-5.764-6.965c0,0-0.877-0.828,0.074-0.828s3.248,0,3.248,0s0-0.557,0-1.416c0-2.449,0-6.906,0-8.723c0,0-0.129-0.494,0.615-0.494c0.75,0,4.035,0,4.572,0c0.536,0,0.524,0.416,0.524,0.416c0,1.762,0,6.373,0,8.742c0,0.768,0,1.266,0,1.266s1.842,0,2.998,0c1.154,0,0.285,0.867,0.285,0.867s-4.904,6.51-5.588,7.193C15.092,18.979,14.62,18.426,14.62,18.426z"
fill="#aaaaaa"
/>
</g>
</svg>
</div>
<div
class="text style-scope ytmusic-menu-navigation-item-renderer"
id="ytmcustom-download"
>
Download
</div>
</div>
</div>

View File

@ -0,0 +1,116 @@
export interface Preset {
extension?: string | null;
ffmpegArgs: string[];
}
// Presets for FFmpeg
export const DefaultPresetList: Record<string, Preset> = {
'mp3 (256kbps)': {
extension: 'mp3',
ffmpegArgs: ['-b:a', '256k'],
},
'Source': {
extension: undefined,
ffmpegArgs: ['-acodec', 'copy'],
},
'Custom': {
extension: null,
ffmpegArgs: [],
}
};
export interface YouTubeFormat {
itag: number;
container: string;
content: string;
resolution: string;
bitrate: string;
range: string;
vrOr3D: string;
}
// converted from https://gist.github.com/sidneys/7095afe4da4ae58694d128b1034e01e2#file-youtube_format_code_itag_list-md
export const YoutubeFormatList: YouTubeFormat[] = [
{ itag: 5, container: 'flv', content: 'audio/video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 6, container: 'flv', content: 'audio/video', resolution: '270p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 17, container: '3gp', content: 'audio/video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 18, container: 'mp4', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 22, container: 'mp4', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 34, container: 'flv', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 35, container: 'flv', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 36, container: '3gp', content: 'audio/video', resolution: '180p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 37, container: 'mp4', content: 'audio/video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 38, container: 'mp4', content: 'audio/video', resolution: '3072p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 43, container: 'webm', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 44, container: 'webm', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 45, container: 'webm', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 46, container: 'webm', content: 'audio/video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 82, container: 'mp4', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 83, container: 'mp4', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 84, container: 'mp4', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 85, container: 'mp4', content: 'audio/video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 91, container: 'hls', content: 'audio/video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 92, container: 'hls', content: 'audio/video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 93, container: 'hls', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 94, container: 'hls', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 95, container: 'hls', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 96, container: 'hls', content: 'audio/video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '-' },
{ itag: 100, container: 'webm', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 101, container: 'webm', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 102, container: 'webm', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '3D' },
{ itag: 132, container: 'hls', content: 'audio/video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 133, container: 'mp4', content: 'video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 134, container: 'mp4', content: 'video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 135, container: 'mp4', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 136, container: 'mp4', content: 'video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 137, container: 'mp4', content: 'video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 138, container: 'mp4', content: 'video', resolution: '2160p60', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 139, container: 'm4a', content: 'audio', resolution: '-', bitrate: '48k', range: '-', vrOr3D: '' },
{ itag: 140, container: 'm4a', content: 'audio', resolution: '-', bitrate: '128k', range: '-', vrOr3D: '' },
{ itag: 141, container: 'm4a', content: 'audio', resolution: '-', bitrate: '256k', range: '-', vrOr3D: '' },
{ itag: 151, container: 'hls', content: 'audio/video', resolution: '72p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 160, container: 'mp4', content: 'video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 167, container: 'webm', content: 'video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 168, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 169, container: 'webm', content: 'video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 171, container: 'webm', content: 'audio', resolution: '-', bitrate: '128k', range: '-', vrOr3D: '' },
{ itag: 218, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 219, container: 'webm', content: 'video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 242, container: 'webm', content: 'video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 243, container: 'webm', content: 'video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 244, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 245, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 246, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 247, container: 'webm', content: 'video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 248, container: 'webm', content: 'video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 249, container: 'webm', content: 'audio', resolution: '-', bitrate: '50k', range: '-', vrOr3D: '' },
{ itag: 250, container: 'webm', content: 'audio', resolution: '-', bitrate: '70k', range: '-', vrOr3D: '' },
{ itag: 251, container: 'webm', content: 'audio', resolution: '-', bitrate: '160k', range: '-', vrOr3D: '' },
{ itag: 264, container: 'mp4', content: 'video', resolution: '1440p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 266, container: 'mp4', content: 'video', resolution: '2160p60', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 271, container: 'webm', content: 'video', resolution: '1440p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 272, container: 'webm', content: 'video', resolution: '4320p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 278, container: 'webm', content: 'video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 298, container: 'mp4', content: 'video', resolution: '720p60', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 299, container: 'mp4', content: 'video', resolution: '1080p60', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 302, container: 'webm', content: 'video', resolution: '720p60', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 303, container: 'webm', content: 'video', resolution: '1080p60', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 308, container: 'webm', content: 'video', resolution: '1440p60', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 313, container: 'webm', content: 'video', resolution: '2160p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 315, container: 'webm', content: 'video', resolution: '2160p60', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 330, container: 'webm', content: 'video', resolution: '144p60', bitrate: '-', range: 'hdr', vrOr3D: '' },
{ itag: 331, container: 'webm', content: 'video', resolution: '240p60', bitrate: '-', range: 'hdr', vrOr3D: '' },
{ itag: 332, container: 'webm', content: 'video', resolution: '360p60', bitrate: '-', range: 'hdr', vrOr3D: '' },
{ itag: 333, container: 'webm', content: 'video', resolution: '480p60', bitrate: '-', range: 'hdr', vrOr3D: '' },
{ itag: 334, container: 'webm', content: 'video', resolution: '720p60', bitrate: '-', range: 'hdr', vrOr3D: '' },
{ itag: 335, container: 'webm', content: 'video', resolution: '1080p60', bitrate: '-', range: 'hdr', vrOr3D: '' },
{ itag: 336, container: 'webm', content: 'video', resolution: '1440p60', bitrate: '-', range: 'hdr', vrOr3D: '' },
{ itag: 337, container: 'webm', content: 'video', resolution: '2160p60', bitrate: '-', range: 'hdr', vrOr3D: '' },
{ itag: 272, container: 'webm', content: 'video', resolution: '2880p/4320p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 399, container: 'mp4', content: 'video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 400, container: 'mp4', content: 'video', resolution: '1440p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 401, container: 'mp4', content: 'video', resolution: '2160p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 402, container: 'mp4', content: 'video', resolution: '2880p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 571, container: 'mp4', content: 'video', resolution: '3840p', bitrate: '-', range: '-', vrOr3D: '' },
{ itag: 702, container: 'mp4', content: 'video', resolution: '3840p', bitrate: '-', range: '-', vrOr3D: '' },
];

View File

@ -0,0 +1,30 @@
import { app, BrowserWindow } from 'electron';
import is from 'electron-is';
export const getFolder = (customFolder: string) => customFolder || app.getPath('downloads');
export const defaultMenuDownloadLabel = 'Download playlist';
export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
win.webContents.send('downloader-feedback', message);
};
export const cropMaxWidth = (image: Electron.NativeImage) => {
const imageSize = image.getSize();
// Standart YouTube artwork width with margins from both sides is 280 + 720 + 280
if (imageSize.width === 1280 && imageSize.height === 720) {
return image.crop({
x: 280,
y: 0,
width: 720,
height: 720,
});
}
return image;
};
export const setBadge = (n: number) => {
if (is.linux() || is.macOS()) {
app.setBadgeCount(n);
}
};

View File

@ -0,0 +1,45 @@
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
const exponentialVolume = () => {
// Manipulation exponent, higher value = lower volume
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
// 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
const EXPONENT = 3;
const storedOriginalVolumes = new WeakMap<HTMLMediaElement, number>();
const propertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'volume',
);
Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
get(this: HTMLMediaElement) {
const lowVolume = propertyDescriptor?.get?.call(this) as number ?? 0;
const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
// The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
// To avoid this, I'll store the unmodified volume to return it when read here.
// This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
// To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0;
const storedDeviation = Math.abs(
storedOriginalVolume - calculatedOriginalVolume,
);
return storedDeviation < 0.01
? storedOriginalVolume
: calculatedOriginalVolume;
},
set(this: HTMLMediaElement, originalVolume: number) {
const lowVolume = originalVolume ** EXPONENT;
storedOriginalVolumes.set(this, originalVolume);
propertyDescriptor?.set?.call(this, lowVolume);
},
});
};
export default () =>
document.addEventListener('apiLoaded', exponentialVolume, {
once: true,
passive: true,
});

View File

@ -0,0 +1,3 @@
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="m4.21 4.387.083-.094a1 1 0 0 1 1.32-.083l.094.083L12 10.585l6.293-6.292a1 1 0 1 1 1.414 1.414L13.415 12l6.292 6.293a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.32.083l-.094-.083L12 13.415l-6.293 6.292a1 1 0 0 1-1.414-1.414L10.585 12 4.293 5.707a1 1 0 0 1-.083-1.32l.083-.094-.083.094Z"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@ -0,0 +1,3 @@
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 17h12a1 1 0 0 1 .117 1.993L15 19H3a1 1 0 0 1-.117-1.993L3 17h12H3Zm0-6h18a1 1 0 0 1 .117 1.993L21 13H3a1 1 0 0 1-.117-1.993L3 11h18H3Zm0-6h15a1 1 0 0 1 .117 1.993L18 7H3a1 1 0 0 1-.117-1.993L3 5h15H3Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -0,0 +1,3 @@
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M3.755 12.5h16.492a.75.75 0 0 0 0-1.5H3.755a.75.75 0 0 0 0 1.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@ -0,0 +1,3 @@
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#ffffff" d="M7.518 5H6.009a3.25 3.25 0 0 1 3.24-3h8.001A4.75 4.75 0 0 1 22 6.75v8a3.25 3.25 0 0 1-3 3.24v-1.508a1.75 1.75 0 0 0 1.5-1.732v-8a3.25 3.25 0 0 0-3.25-3.25h-8A1.75 1.75 0 0 0 7.518 5ZM5.25 6A3.25 3.25 0 0 0 2 9.25v9.5A3.25 3.25 0 0 0 5.25 22h9.5A3.25 3.25 0 0 0 18 18.75v-9.5A3.25 3.25 0 0 0 14.75 6h-9.5ZM3.5 9.25c0-.966.784-1.75 1.75-1.75h9.5c.967 0 1.75.784 1.75 1.75v9.5a1.75 1.75 0 0 1-1.75 1.75h-9.5a1.75 1.75 0 0 1-1.75-1.75v-9.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 546 B

View File

@ -0,0 +1,67 @@
import { register } from 'electron-localshortcut';
import { BrowserWindow, Menu, MenuItem, ipcMain } from 'electron';
import titlebarStyle from './titlebar.css';
import { injectCSS } from '../utils';
// Tracks menu visibility
export default (win: BrowserWindow) => {
injectCSS(win.webContents, titlebarStyle);
win.once('ready-to-show', () => {
register(win, '`', () => {
win.webContents.send('toggleMenu');
});
});
ipcMain.handle(
'get-menu',
() => JSON.parse(JSON.stringify(
Menu.getApplicationMenu(),
(key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined),
),
);
const getMenuItemById = (commandId: number): MenuItem | null => {
const menu = Menu.getApplicationMenu();
let target: MenuItem | null = null;
const stack = [...menu?.items ?? []];
while (stack.length > 0) {
const now = stack.shift();
now?.submenu?.items.forEach((item) => stack.push(item));
if (now?.commandId === commandId) {
target = now;
break;
}
}
return target;
};
ipcMain.handle('menu-event', (event, commandId: number) => {
const target = getMenuItemById(commandId);
if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
});
ipcMain.handle('get-menu-by-id', (_, commandId: number) => {
const result = getMenuItemById(commandId);
return JSON.parse(JSON.stringify(
result,
(key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined),
);
});
ipcMain.handle('window-is-maximized', () => win.isMaximized());
ipcMain.handle('window-close', () => win.close());
ipcMain.handle('window-minimize', () => win.minimize());
ipcMain.handle('window-maximize', () => win.maximize());
win.on('maximize', () => win.webContents.send('window-maximize'));
ipcMain.handle('window-unmaximize', () => win.unmaximize());
win.on('unmaximize', () => win.webContents.send('window-unmaximize'));
};

View File

@ -0,0 +1,163 @@
import { ipcRenderer, Menu } from 'electron';
import { createPanel } from './menu/panel';
import logo from './assets/menu.svg';
import close from './assets/close.svg';
import minimize from './assets/minimize.svg';
import maximize from './assets/maximize.svg';
import unmaximize from './assets/unmaximize.svg';
import { isEnabled } from '../../config/plugins';
import config from '../../config';
function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector);
}
const isMacOS = navigator.userAgent.includes('Macintosh');
const isNotWindowsOrMacOS = !navigator.userAgent.includes('Windows') && !isMacOS;
export default async () => {
let hideMenu = config.get('options.hideMenu');
const titleBar = document.createElement('title-bar');
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
let maximizeButton: HTMLButtonElement;
if (isMacOS) titleBar.style.setProperty('--offset-left', '70px');
logo.classList.add('title-bar-icon');
const logoClick = () => {
hideMenu = !hideMenu;
let visibilityStyle: string;
if (hideMenu) {
visibilityStyle = 'hidden';
} else {
visibilityStyle = 'visible';
}
const menus = document.querySelectorAll<HTMLElement>('menu-button');
menus.forEach((menu) => {
menu.style.visibility = visibilityStyle;
});
};
logo.onclick = logoClick;
ipcRenderer.on('toggleMenu', logoClick);
if (!isMacOS) titleBar.appendChild(logo);
document.body.appendChild(titleBar);
titleBar.appendChild(logo);
const addWindowControls = async () => {
// Create window control buttons
const minimizeButton = document.createElement('button');
minimizeButton.classList.add('window-control');
minimizeButton.appendChild(minimize);
minimizeButton.onclick = () => ipcRenderer.invoke('window-minimize');
maximizeButton = document.createElement('button');
if (await ipcRenderer.invoke('window-is-maximized')) {
maximizeButton.classList.add('window-control');
maximizeButton.appendChild(unmaximize);
} else {
maximizeButton.classList.add('window-control');
maximizeButton.appendChild(maximize);
}
maximizeButton.onclick = async () => {
if (await ipcRenderer.invoke('window-is-maximized')) {
// change icon to maximize
maximizeButton.removeChild(maximizeButton.firstChild!);
maximizeButton.appendChild(maximize);
// call unmaximize
await ipcRenderer.invoke('window-unmaximize');
} else {
// change icon to unmaximize
maximizeButton.removeChild(maximizeButton.firstChild!);
maximizeButton.appendChild(unmaximize);
// call maximize
await ipcRenderer.invoke('window-maximize');
}
};
const closeButton = document.createElement('button');
closeButton.classList.add('window-control');
closeButton.appendChild(close);
closeButton.onclick = () => ipcRenderer.invoke('window-close');
// Create a container div for the window control buttons
const windowControlsContainer = document.createElement('div');
windowControlsContainer.classList.add('window-controls-container');
windowControlsContainer.appendChild(minimizeButton);
windowControlsContainer.appendChild(maximizeButton);
windowControlsContainer.appendChild(closeButton);
// Add window control buttons to the title bar
titleBar.appendChild(windowControlsContainer);
};
if (isNotWindowsOrMacOS) await addWindowControls();
if (navBar) {
const observer = new MutationObserver((mutations) => {
mutations.forEach(() => {
titleBar.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor);
document.querySelector('html')!.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor);
});
});
observer.observe(navBar, { attributes : true, attributeFilter : ['style'] });
}
const updateMenu = async () => {
const children = [...titleBar.children];
children.forEach((child) => {
if (child !== logo) child.remove();
});
const menu = await ipcRenderer.invoke('get-menu') as Menu | null;
if (!menu) return;
menu.items.forEach((menuItem) => {
const menu = document.createElement('menu-button');
createPanel(titleBar, menu, menuItem.submenu?.items ?? []);
menu.append(menuItem.label);
titleBar.appendChild(menu);
if (hideMenu) {
menu.style.visibility = 'hidden';
}
});
if (isNotWindowsOrMacOS) await addWindowControls();
};
await updateMenu();
document.title = 'Youtube Music';
ipcRenderer.on('refreshMenu', () => updateMenu());
ipcRenderer.on('window-maximize', () => {
maximizeButton.removeChild(maximizeButton.firstChild!);
maximizeButton.appendChild(unmaximize);
});
ipcRenderer.on('window-unmaximize', () => {
maximizeButton.removeChild(maximizeButton.firstChild!);
maximizeButton.appendChild(maximize);
});
if (isEnabled('picture-in-picture')) {
ipcRenderer.on('pip-toggle', () => {
updateMenu();
});
}
// Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it)
document.addEventListener('apiLoaded', () => {
const htmlHeadStyle = $('head > div > style');
if (htmlHeadStyle) {
// HACK: This is a hack to remove the scrollbar width
htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {');
}
}, { once: true, passive: true });
};

View File

@ -0,0 +1,10 @@
const Icons = {
submenu: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><polyline points="9 6 15 12 9 18" /></svg>',
checkbox: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10" /></svg>',
radio: {
checked: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style="padding: 2px"><path fill="currentColor" d="M10,5 C7.2,5 5,7.2 5,10 C5,12.8 7.2,15 10,15 C12.8,15 15,12.8 15,10 C15,7.2 12.8,5 10,5 L10,5 Z M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z" /></svg>',
unchecked: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style="padding: 2px"><path fill="currentColor" d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z" /></svg>',
},
};
export default Icons;

View File

@ -0,0 +1,134 @@
import { nativeImage, type MenuItem, ipcRenderer, Menu } from 'electron';
import Icons from './icons';
import { ElementFromHtml } from '../../utils';
interface PanelOptions {
placement?: 'bottom' | 'right';
order?: number;
}
export const createPanel = (
parent: HTMLElement,
anchor: HTMLElement,
items: MenuItem[],
options: PanelOptions = { placement: 'bottom', order: 0 },
) => {
const childPanels: HTMLElement[] = [];
const panel = document.createElement('menu-panel');
panel.style.zIndex = `${options.order}`;
const updateIconState = (iconWrapper: HTMLElement, item: MenuItem) => {
if (item.type === 'checkbox') {
if (item.checked) iconWrapper.innerHTML = Icons.checkbox;
else iconWrapper.innerHTML = '';
} else if (item.type === 'radio') {
if (item.checked) iconWrapper.innerHTML = Icons.radio.checked;
else iconWrapper.innerHTML = Icons.radio.unchecked;
} else {
const nativeImageIcon = typeof item.icon === 'string' ? nativeImage.createFromPath(item.icon) : item.icon;
const iconURL = nativeImageIcon?.toDataURL();
if (iconURL) iconWrapper.style.background = `url(${iconURL})`;
}
};
const radioGroups: [MenuItem, HTMLElement][] = [];
items.map((item) => {
if (item.type === 'separator') return panel.appendChild(document.createElement('menu-separator'));
const menu = document.createElement('menu-item');
const iconWrapper = document.createElement('menu-icon');
updateIconState(iconWrapper, item);
menu.appendChild(iconWrapper);
menu.append(item.label);
menu.addEventListener('click', async () => {
await ipcRenderer.invoke('menu-event', item.commandId);
const menuItem = await ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
if (menuItem) {
updateIconState(iconWrapper, menuItem);
if (menuItem.type === 'radio') {
await Promise.all(
radioGroups.map(async ([item, iconWrapper]) => {
if (item.commandId === menuItem.commandId) return;
const newItem = await ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
if (newItem) updateIconState(iconWrapper, newItem);
})
);
}
}
});
if (item.type === 'radio') {
radioGroups.push([item, iconWrapper]);
}
if (item.type === 'submenu') {
const subMenuIcon = document.createElement('menu-icon');
subMenuIcon.appendChild(ElementFromHtml(Icons.submenu));
menu.appendChild(subMenuIcon);
const [child, , children] = createPanel(parent, menu, item.submenu?.items ?? [], {
placement: 'right',
order: (options?.order ?? 0) + 1,
});
childPanels.push(child);
children.push(...children);
}
panel.appendChild(menu);
});
/* methods */
const isOpened = () => panel.getAttribute('open') === 'true';
const close = () => panel.setAttribute('open', 'false');
const open = () => {
const rect = anchor.getBoundingClientRect();
if (options.placement === 'bottom') {
panel.style.setProperty('--x', `${rect.x}px`);
panel.style.setProperty('--y', `${rect.y + rect.height}px`);
} else {
panel.style.setProperty('--x', `${rect.x + rect.width}px`);
panel.style.setProperty('--y', `${rect.y}px`);
}
panel.setAttribute('open', 'true');
// Children are placed below their parent item, which can cause
// long lists to squeeze their children at the bottom of the screen
// (This needs to be done *after* setAttribute)
panel.classList.remove('position-by-bottom');
if (options.placement === 'right' && panel.scrollHeight > panel.clientHeight ) {
panel.style.setProperty('--y', `${rect.y + rect.height}px`);
panel.classList.add('position-by-bottom');
}
};
anchor.addEventListener('click', () => {
if (isOpened()) close();
else open();
});
document.body.addEventListener('click', (event) => {
const path = event.composedPath();
const isInside = path.some((it) => it === panel || it === anchor || childPanels.includes(it as HTMLElement));
if (!isInside) close();
});
parent.appendChild(panel);
return [
panel,
{ isOpened, close, open },
childPanels,
] as const;
};

View File

@ -0,0 +1,180 @@
:root {
--titlebar-background-color: #030303;
--menu-bar-height: 36px;
}
title-bar {
-webkit-app-region: drag;
box-sizing: border-box;
position: fixed;
top: 0;
z-index: 10000000;
width: 100%;
height: var(--menu-bar-height, 36px);
display: flex;
flex-flow: row;
justify-content: flex-start;
align-items: center;
gap: 4px;
color: #f1f1f1;
font-size: 14px;
padding: 4px 12px;
padding-left: var(--offset-left, 12px);
background-color: var(--titlebar-background-color, #030303);
user-select: none;
transition: opacity 200ms ease 0s, background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
}
menu-button {
-webkit-app-region: none;
display: flex;
justify-content: center;
align-items: center;
align-self: stretch;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
}
menu-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
menu-panel {
position: fixed;
top: var(--y, 0);
left: var(--x, 0);
max-height: calc(100vh - var(--menu-bar-height, 36px) - 16px - var(--y, 0));
display: flex;
flex-flow: column;
justify-content: flex-start;
align-items: stretch;
gap: 0;
overflow: auto;
padding: 4px;
border-radius: 8px;
pointer-events: none;
background-color: color-mix(in srgb, var(--titlebar-background-color, #030303) 50%, rgba(0, 0, 0, 0.1));
backdrop-filter: blur(8px);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 0;
opacity: 0;
transform: scale(0.8);
transform-origin: top left;
transition: opacity 200ms ease 0s, transform 200ms ease 0s;
}
menu-panel[open="true"] {
pointer-events: all;
opacity: 1;
transform: scale(1);
}
menu-panel.position-by-bottom {
top: unset;
bottom: calc(100vh - var(--y, 100%));
max-height: calc(var(--y, 0) - var(--menu-bar-height, 36px) - 16px);
}
menu-item {
-webkit-app-region: none;
min-height: 32px;
height: 32px;
display: grid;
grid-template-columns: 32px 1fr minmax(32px, auto);
justify-content: flex-start;
align-items: center;
border-radius: 4px;
cursor: pointer;
}
menu-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
menu-item > menu-icon {
height: 32px;
padding: 4px;
box-sizing: border-box;
}
menu-separator {
min-height: 1px;
height: 1px;
margin: 4px 0;
background-color: rgba(255, 255, 255, 0.2);
}
/* classes */
.title-bar-icon {
height: calc(100% - 8px);
object-fit: cover;
margin-left: -4px;
}
/* Window control container */
.window-controls-container {
-webkit-app-region: no-drag;
display: flex;
justify-content: flex-end; /* Align to the right end of the title-bar */
align-items: center;
gap: 4px; /* Add spacing between the window control buttons */
position: absolute; /* Position it absolutely within title-bar */
right: 4px; /* Adjust the right position as needed */
}
/* Window control buttons */
.window-control {
width: 24px;
height: 24px;
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
color: #f1f1f1;
font-size: 14px;
padding: 0;
}
/* youtube-music style */
ytmusic-app-layout {
margin-top: var(--menu-bar-height, 36px) !important;
}
ytmusic-app-layout>[slot=nav-bar], #nav-bar-background.ytmusic-app-layout {
top: var(--menu-bar-height, 36px) !important;
}
#nav-bar-divider.ytmusic-app-layout {
top: calc(var(--ytmusic-nav-bar-height) + var(--menu-bar-height, 36px)) !important;
}
ytmusic-app[is-bauhaus-sidenav-enabled] #guide-spacer.ytmusic-app,
ytmusic-app[is-bauhaus-sidenav-enabled] #mini-guide-spacer.ytmusic-app {
margin-top: calc(var(--ytmusic-nav-bar-height) + var(--menu-bar-height, 36px)) !important;
}
ytmusic-app-layout>[slot=player-page] {
margin-top: var(--menu-bar-height);
height: calc(100vh - var(--menu-bar-height) - var(--ytmusic-nav-bar-height) - var(--ytmusic-player-bar-height)) !important;
}
ytmusic-guide-renderer {
height: calc(100vh - var(--menu-bar-height) - var(--ytmusic-nav-bar-height)) !important;
}

203
src/plugins/last-fm/back.ts Normal file
View File

@ -0,0 +1,203 @@
import crypto from 'node:crypto';
import { BrowserWindow, net, shell } from 'electron';
import { setOptions } from '../../config/plugins';
import registerCallback, { SongInfo } from '../../providers/song-info';
import defaultConfig from '../../config/defaults';
import type { ConfigType } from '../../config/dynamic';
type LastFMOptions = ConfigType<'last-fm'>;
interface LastFmData {
method: string,
timestamp?: number,
}
interface LastFmSongData {
track?: string,
duration?: number,
artist?: string,
album?: string,
api_key: string,
sk?: string,
format: string,
method: string,
timestamp?: number,
api_sig?: string,
}
const createFormData = (parameters: LastFmSongData) => {
// Creates the body for in the post request
const formData = new URLSearchParams();
for (const key in parameters) {
formData.append(key, String(parameters[key as keyof LastFmSongData]));
}
return formData;
};
const createQueryString = (parameters: Record<string, unknown>, apiSignature: string) => {
// Creates a querystring
const queryData = [];
parameters.api_sig = apiSignature;
for (const key in parameters) {
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(parameters[key]))}`);
}
return '?' + queryData.join('&');
};
const createApiSig = (parameters: LastFmSongData, secret: string) => {
// This function creates the api signature, see: https://www.last.fm/api/authspec
const keys = Object.keys(parameters);
keys.sort();
let sig = '';
for (const key of keys) {
if (String(key) === 'format') {
continue;
}
sig += `${key}${parameters[key as keyof LastFmSongData]}`;
}
sig += secret;
sig = crypto.createHash('md5').update(sig, 'utf-8').digest('hex');
return sig;
};
const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFMOptions) => {
// Creates and stores the auth token
const data = {
method: 'auth.gettoken',
api_key: apiKey,
format: 'json',
};
const apiSigature = createApiSig(data, secret);
const response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`);
const json = await response.json() as Record<string, string>;
return json?.token;
};
const authenticate = async (config: LastFMOptions) => {
// Asks the user for authentication
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
};
const getAndSetSessionKey = async (config: LastFMOptions) => {
// Get and store the session key
const data = {
api_key: config.api_key,
format: 'json',
method: 'auth.getsession',
token: config.token,
};
const apiSignature = createApiSig(data, config.secret);
const response = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`);
const json = await response.json() as {
error?: string,
session?: {
key: string,
}
};
if (json.error) {
config.token = await createToken(config);
await authenticate(config);
setOptions('last-fm', config);
}
if (json.session) {
config.session_key = json.session.key;
}
setOptions('last-fm', config);
return config;
};
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data: LastFmData) => {
// This sends a post request to the api, and adds the common data
if (!config.session_key) {
await getAndSetSessionKey(config);
}
const postData: LastFmSongData = {
track: songInfo.title,
duration: songInfo.songDuration,
artist: songInfo.artist,
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
api_key: config.api_key,
sk: config.session_key,
format: 'json',
...data,
};
postData.api_sig = createApiSig(postData, config.secret);
const formData = createFormData(postData);
net.fetch('https://ws.audioscrobbler.com/2.0/', { method: 'POST', body: formData })
.catch(async (error: {
response?: {
data?: {
error: number,
}
}
}) => {
if (error?.response?.data?.error === 9) {
// Session key is invalid, so remove it from the config and reauthenticate
config.session_key = undefined;
config.token = await createToken(config);
await authenticate(config);
setOptions('last-fm', config);
}
});
};
const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => {
// This adds one scrobbled song to last.fm
const data = {
method: 'track.scrobble',
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
};
postSongDataToAPI(songInfo, config, data);
};
const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => {
// This sets the now playing status in last.fm
const data = {
method: 'track.updateNowPlaying',
};
postSongDataToAPI(songInfo, config, data);
};
// This will store the timeout that will trigger addScrobble
let scrobbleTimer: NodeJS.Timeout | undefined;
const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => {
if (!config.api_root) {
// Settings are not present, creating them with the default values
config = defaultConfig.plugins['last-fm'];
config.enabled = true;
setOptions('last-fm', config);
}
if (!config.session_key) {
// Not authenticated
config = await getAndSetSessionKey(config);
}
registerCallback((songInfo) => {
// Set remove the old scrobble timer
clearTimeout(scrobbleTimer);
if (!songInfo.isPaused) {
setNowPlaying(songInfo, config);
// Scrobble when the song is halfway through, or has passed the 4-minute mark
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
// Scrobble still needs to happen
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
}
}
});
};
export default lastfm;

View File

@ -0,0 +1,82 @@
import { BrowserWindow , net } from 'electron';
import registerCallback from '../../providers/song-info';
const secToMilisec = (t?: number) => t ? Math.round(Number(t) * 1e3) : undefined;
const previousStatePaused = null;
type LumiaData = {
origin: string;
eventType: string;
url?: string;
videoId?: string;
playlistId?: string;
cover?: string|null;
cover_url?: string|null;
title?: string;
artists?: string[];
status?: string;
progress?: number;
duration?: number;
album_url?: string|null;
album?: string|null;
views?: number;
isPaused?: boolean;
}
const data: LumiaData = {
origin: 'youtubemusic',
eventType: 'switchSong',
};
const post = (data: LumiaData) => {
const port = 39231;
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': '*',
};
const url = `http://localhost:${port}/api/media`;
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
.catch((error: { code: number, errno: number }) => {
console.log(
`Error: '${
error.code || error.errno
}' - when trying to access lumiastream webserver at port ${port}`
);
});
};
export default (_: BrowserWindow) => {
registerCallback((songInfo) => {
if (!songInfo.title && !songInfo.artist) {
return;
}
if (previousStatePaused === null) {
data.eventType = 'switchSong';
} else if (previousStatePaused !== songInfo.isPaused) {
data.eventType = 'playPause';
}
data.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds);
data.url = songInfo.url;
data.videoId = songInfo.videoId;
data.playlistId = songInfo.playlistId;
data.cover = songInfo.imageSrc;
data.cover_url = songInfo.imageSrc;
data.album_url = songInfo.imageSrc;
data.title = songInfo.title;
data.artists = [songInfo.artist];
data.status = songInfo.isPaused ? 'stopped' : 'playing';
data.isPaused = songInfo.isPaused;
data.album = songInfo.album;
data.views = songInfo.views;
post(data);
});
};

View File

@ -0,0 +1,124 @@
import { BrowserWindow, ipcMain, net } from 'electron';
import is from 'electron-is';
import { convert } from 'html-to-text';
import style from './style.css';
import { GetGeniusLyric } from './types';
import { cleanupName, SongInfo } from '../../providers/song-info';
import { injectCSS } from '../utils';
import type { ConfigType } from '../../config/dynamic';
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false;
export type LyricGeniusType = ConfigType<'lyrics-genius'>;
export default (win: BrowserWindow, options: LyricGeniusType) => {
if (options.romanizedLyrics) {
revRomanized = true;
}
injectCSS(win.webContents, style);
ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => {
const metadata = extractedSongInfo;
return await fetchFromGenius(metadata);
});
};
export const toggleRomanized = () => {
revRomanized = !revRomanized;
};
export const fetchFromGenius = async (metadata: SongInfo) => {
const songTitle = `${cleanupName(metadata.title)}`;
const songArtist = `${cleanupName(metadata.artist)}`;
let lyrics: string | null;
/* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise normal
Genius Lyrics behavior is observed.
*/
let hasAsianChars = false;
if (revRomanized && (eastAsianChars.test(songTitle) || eastAsianChars.test(songArtist))) {
lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
hasAsianChars = true;
} else {
lyrics = await getLyricsList(`${songArtist} ${songTitle}`);
}
/* If the romanization toggle is on, and we did not detect any characters in the title or artist, we do a check
for characters in the lyrics themselves. If this check proves true, we search for Romanized lyrics.
*/
if (revRomanized && !hasAsianChars && lyrics && eastAsianChars.test(lyrics)) {
lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
}
return lyrics;
};
/**
* Fetches a JSON of songs which is then parsed and passed into getLyrics to get the lyrical content of the first song
* @param {*} queryString
* @returns The lyrics of the first song found using the Genius-Lyrics API
*/
const getLyricsList = async (queryString: string): Promise<string | null> => {
const response = await net.fetch(
`https://genius.com/api/search/multi?per_page=5&q=${encodeURIComponent(queryString)}`,
);
if (!response.ok) {
return null;
}
/* Fetch the first URL with the api, giving a collection of song results.
Pick the first song, parsing the json given by the API.
*/
const info = await response.json() as GetGeniusLyric;
const url = info
?.response
?.sections
?.find((section) => section.type === 'song')?.hits[0]?.result?.url;
if (url) {
return await getLyrics(url);
} else {
return null;
}
};
/**
*
* @param {*} url
* @returns The lyrics of the song URL provided, null if none
*/
const getLyrics = async (url: string): Promise<string | null> => {
const response = await net.fetch(url);
if (!response.ok) {
return null;
}
if (is.dev()) {
console.log('Fetching lyrics from Genius:', url);
}
const html = await response.text();
return convert(html, {
baseElements: {
selectors: ['[class^="Lyrics__Container"]', '.lyrics'],
},
selectors: [
{
selector: 'a',
format: 'linkFormatter',
},
],
formatters: {
// Remove links by keeping only the content
linkFormatter(element, walk, builder) {
walk(element.children, builder);
},
},
});
};

View File

@ -0,0 +1,108 @@
import { ipcRenderer } from 'electron';
import is from 'electron-is';
import type { SongInfo } from '../../providers/song-info';
export default () => {
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
lyricsContainer.innerHTML = `
<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
${lyrics?.replaceAll(/\r\n|\r|\n/g, '<br/>') ?? 'Could not retrieve lyrics from genius'}
</div>
<yt-formatted-string class="footer style-scope ytmusic-description-shelf-renderer" style="align-self: baseline">
</yt-formatted-string>
`;
if (lyrics) {
const footer = lyricsContainer.querySelector('.footer');
if (footer) {
footer.textContent = 'Source: Genius';
}
}
};
let unregister: (() => void) | null = null;
ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => {
unregister?.();
setTimeout(async () => {
const tabList = document.querySelectorAll<HTMLElement>('tp-yt-paper-tab');
const tabs = {
upNext: tabList[0],
lyrics: tabList[1],
discover: tabList[2],
};
// Check if disabled
if (!tabs.lyrics?.hasAttribute('disabled')) return;
const lyrics = await ipcRenderer.invoke(
'search-genius-lyrics',
extractedSongInfo,
) as string | null;
if (!lyrics) {
// Delete previous lyrics if tab is open and couldn't get new lyrics
tabs.upNext.click();
return;
}
if (is.dev()) {
console.log('Fetched lyrics from Genius');
}
const tryToInjectLyric = (callback?: () => void) => {
const lyricsContainer = document.querySelector(
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
);
if (lyricsContainer) {
callback?.();
setLyrics(lyricsContainer, lyrics);
applyLyricsTabState();
}
};
const applyLyricsTabState = () => {
if (lyrics) {
tabs.lyrics.removeAttribute('disabled');
tabs.lyrics.removeAttribute('aria-disabled');
} else {
tabs.lyrics.setAttribute('disabled', '');
tabs.lyrics.setAttribute('aria-disabled', '');
}
};
const lyricsTabHandler = () => {
const tabContainer = document.querySelector('ytmusic-tab-renderer');
if (!tabContainer) return;
const observer = new MutationObserver((_, observer) => {
tryToInjectLyric(() => observer.disconnect());
});
observer.observe(tabContainer, {
attributes: true,
childList: true,
subtree: true,
});
};
applyLyricsTabState();
tabs.discover.addEventListener('click', applyLyricsTabState);
tabs.lyrics.addEventListener('click', lyricsTabHandler);
tabs.upNext.addEventListener('click', applyLyricsTabState);
tryToInjectLyric();
unregister = () => {
tabs.discover.removeEventListener('click', applyLyricsTabState);
tabs.lyrics.removeEventListener('click', lyricsTabHandler);
tabs.upNext.removeEventListener('click', applyLyricsTabState);
};
}, 500);
});
};

View File

@ -0,0 +1,19 @@
import { BrowserWindow, MenuItem } from 'electron';
import { LyricGeniusType, toggleRomanized } from './back';
import { setOptions } from '../../config/plugins';
import { MenuTemplate } from '../../menu';
export default (_: BrowserWindow, options: LyricGeniusType): MenuTemplate => [
{
label: 'Romanized Lyrics',
type: 'checkbox',
checked: options.romanizedLyrics,
click(item: MenuItem) {
options.romanizedLyrics = item.checked;
setOptions('lyrics-genius', options);
toggleRomanized();
},
},
];

View File

@ -0,0 +1,12 @@
/* Disable links in Genius lyrics */
.genius-lyrics a {
color: var(--ytmusic-text-primary);
display: inline-block;
pointer-events: none;
text-decoration: none;
}
.description {
font-size: clamp(1.4rem, 1.1vmax, 3rem) !important;
text-align: center !important;
}

View File

@ -0,0 +1,121 @@
export interface GetGeniusLyric {
meta: Meta;
response: Response;
}
export interface Meta {
status: number;
}
export interface Response {
sections: Section[];
}
export interface Section {
type: string;
hits: Hit[];
}
export interface Hit {
highlights: Highlight[];
index: Index;
type: Index;
result: Result;
}
export interface Highlight {
property: string;
value: string;
snippet: boolean;
ranges: Range[];
}
export interface Range {
start: number;
end: number;
}
export enum Index {
Album = 'album',
Lyric = 'lyric',
Song = 'song',
}
export interface Result {
_type: Index;
annotation_count?: number;
api_path: string;
artist_names?: string;
full_title: string;
header_image_thumbnail_url?: string;
header_image_url?: string;
id: number;
instrumental?: boolean;
lyrics_owner_id?: number;
lyrics_state?: LyricsState;
lyrics_updated_at?: number;
path?: string;
pyongs_count?: number | null;
relationships_index_url?: string;
release_date_components: ReleaseDateComponents;
release_date_for_display: string;
release_date_with_abbreviated_month_for_display?: string;
song_art_image_thumbnail_url?: string;
song_art_image_url?: string;
stats?: Stats;
title?: string;
title_with_featured?: string;
updated_by_human_at?: number;
url: string;
featured_artists?: string[];
primary_artist?: Artist;
cover_art_thumbnail_url?: string;
cover_art_url?: string;
name?: string;
name_with_artist?: string;
artist?: Artist;
}
export interface Artist {
_type: Type;
api_path: string;
header_image_url: string;
id: number;
image_url: string;
index_character: IndexCharacter;
is_meme_verified: boolean;
is_verified: boolean;
name: string;
slug: string;
url: string;
iq?: number;
}
// TODO: Add more types
export enum Type {
Artist = 'artist',
}
// TODO: Add more index characters
export enum IndexCharacter {
G = 'g',
Y = 'y',
}
// TODO: Add more states
export enum LyricsState {
Complete = 'complete',
}
export interface ReleaseDateComponents {
year: number;
month: number;
day: number | null;
}
export interface Stats {
unreviewed_annotations: number;
concurrents?: number;
hot: boolean;
pageviews?: number;
}

View File

@ -0,0 +1,13 @@
import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils';
export function handle(win: BrowserWindow) {
injectCSS(win.webContents, style, () => {
win.webContents.send('navigation-css-ready');
});
}
export default handle;

View File

@ -0,0 +1,20 @@
import { ipcRenderer } from 'electron';
import forwardHTML from './templates/forward.html';
import backHTML from './templates/back.html';
import { ElementFromHtml } from '../utils';
export function run() {
ipcRenderer.on('navigation-css-ready', () => {
const forwardButton = ElementFromHtml(forwardHTML);
const backButton = ElementFromHtml(backHTML);
const menu = document.querySelector('#right-content');
if (menu) {
menu.prepend(backButton, forwardButton);
}
});
}
export default run;

View File

@ -0,0 +1,35 @@
.navigation-item {
font-family: Roboto, Noto Naskh Arabic UI, Arial, sans-serif;
font-size: 20px;
line-height: var(--ytmusic-title-1_-_line-height);
font-weight: 500;
--yt-endpoint-color: #fff;
--yt-endpoint-hover-color: #fff;
--yt-endpoint-visited-color: #fff;
display: inline-flex;
align-items: center;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
margin: 0 var(--ytd-rich-grid-item-margin);
}
.navigation-item:hover {
color: #fff;
}
.navigation-icon {
display: inline-flex;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
position: relative;
vertical-align: middle;
fill: var(--iron-icon-fill-color, currentcolor);
stroke: none;
width: var(--iron-icon-width, 24px);
height: var(--iron-icon-height, 24px);
animation: var(--iron-icon_-_animation);
}

View File

@ -0,0 +1,33 @@
<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

@ -0,0 +1,35 @@
<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

@ -0,0 +1,9 @@
import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, style);
};

View File

@ -0,0 +1,37 @@
function removeLoginElements() {
const elementsToRemove = [
'.sign-in-link.ytmusic-nav-bar',
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
];
for (const selector of elementsToRemove) {
const node = document.querySelector(selector);
if (node) {
node.remove();
}
}
// Remove the library button
const libraryIconPath
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
const observer = new MutationObserver(() => {
const menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer',
);
menuEntries.forEach((item) => {
const icon = item.querySelector('path');
if (icon) {
observer.disconnect();
if (icon.getAttribute('d') === libraryIconPath) {
item.remove();
}
}
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
export default removeLoginElements;

View File

@ -0,0 +1,6 @@
.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"],
ytmusic-guide-signin-promo-renderer,
a[href="/music_premium"],
.sign-in-link {
display: none !important;
}

View File

@ -0,0 +1,51 @@
import { BrowserWindow, Notification } from 'electron';
import is from 'electron-is';
import { notificationImage } from './utils';
import config from './config';
import interactive from './interactive';
import registerCallback, { SongInfo } from '../../providers/song-info';
import type { ConfigType } from '../../config/dynamic';
type NotificationOptions = ConfigType<'notifications'>;
const notify = (info: SongInfo) => {
// Send the notification
const currentNotification = new Notification({
title: info.title || 'Playing',
body: info.artist,
icon: notificationImage(info),
silent: true,
urgency: config.get('urgency') as 'normal' | 'critical' | 'low',
});
currentNotification.show();
return currentNotification;
};
const setup = () => {
let oldNotification: Notification;
let currentUrl: string | undefined;
registerCallback((songInfo: SongInfo) => {
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) {
// Close the old notification
oldNotification?.close();
currentUrl = songInfo.url;
// This fixes a weird bug that would cause the notification to be updated instead of showing
setTimeout(() => {
oldNotification = notify(songInfo);
}, 10);
}
});
};
export default (win: BrowserWindow, options: NotificationOptions) => {
// Register the callback for new song information
is.windows() && options.interactive
? interactive(win)
: setup();
};

View File

@ -0,0 +1,5 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('notifications');
export default config;

View File

@ -0,0 +1,257 @@
import path from 'node:path';
import { app, BrowserWindow, ipcMain, Notification } from 'electron';
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
import config from './config';
import getSongControls from '../../providers/song-controls';
import registerCallback, { SongInfo } from '../../providers/song-info';
import { changeProtocolHandler } from '../../providers/protocol-handler';
import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray';
import { getMediaIconLocation, mediaIcons, saveMediaIcon } from '../utils';
let songControls: ReturnType<typeof getSongControls>;
let savedNotification: Notification | undefined;
export default (win: BrowserWindow) => {
songControls = getSongControls(win);
let currentSeconds = 0;
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t);
if (app.isPackaged) {
saveMediaIcon();
}
let savedSongInfo: SongInfo;
let lastUrl: string | undefined;
// Register songInfoCallback
registerCallback((songInfo) => {
if (!songInfo.artist && !songInfo.title) {
return;
}
savedSongInfo = { ...songInfo };
if (!songInfo.isPaused
&& (songInfo.url !== lastUrl || config.get('unpauseNotification'))
) {
lastUrl = songInfo.url;
sendNotification(songInfo);
}
});
if (config.get('trayControls')) {
setTrayOnClick(() => {
if (savedNotification) {
savedNotification.close();
savedNotification = undefined;
} else if (savedSongInfo) {
sendNotification({
...savedSongInfo,
elapsedSeconds: currentSeconds,
});
}
});
setTrayOnDoubleClick(() => {
if (win.isVisible()) {
win.hide();
} else {
win.show();
}
});
}
app.once('before-quit', () => {
savedNotification?.close();
});
changeProtocolHandler(
(cmd) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd as keyof typeof songControls]();
if (config.get('refreshOnPlayPause') && (
cmd === 'pause'
|| (cmd === 'play' && !config.get('unpauseNotification'))
)
) {
setImmediate(() =>
sendNotification({
...savedSongInfo,
isPaused: cmd === 'pause',
elapsedSeconds: currentSeconds,
}),
);
}
}
},
);
};
function sendNotification(songInfo: SongInfo) {
const iconSrc = notificationImage(songInfo);
savedNotification?.close();
let icon: string;
if (typeof iconSrc === 'object') {
icon = iconSrc.toDataURL();
} else {
icon = iconSrc;
}
savedNotification = new Notification({
title: songInfo.title || 'Playing',
body: songInfo.artist,
icon: iconSrc,
silent: true,
// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
toastXml: getXml(songInfo, icon),
});
savedNotification.on('close', () => {
savedNotification = undefined;
});
savedNotification.show();
}
const getXml = (songInfo: SongInfo, iconSrc: string) => {
switch (config.get('toastStyle')) {
default:
case ToastStyles.logo:
case ToastStyles.legacy: {
return xmlLogo(songInfo, iconSrc);
}
case ToastStyles.banner_top_custom: {
return xmlBannerTopCustom(songInfo, iconSrc);
}
case ToastStyles.hero: {
return xmlHero(songInfo, iconSrc);
}
case ToastStyles.banner_bottom: {
return xmlBannerBottom(songInfo, iconSrc);
}
case ToastStyles.banner_centered_bottom: {
return xmlBannerCenteredBottom(songInfo, iconSrc);
}
case ToastStyles.banner_centered_top: {
return xmlBannerCenteredTop(songInfo, iconSrc);
}
}
};
const display = (kind: keyof typeof mediaIcons) => {
if (config.get('toastStyle') === ToastStyles.legacy) {
return `content="${mediaIcons[kind]}"`;
}
return `\
content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
imageUri="file:///${path.resolve(getMediaIconLocation(), `${kind}.png`)}"
`;
};
const getButton = (kind: keyof typeof mediaIcons) =>
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
const getButtons = (isPaused: boolean) => `\
<actions>
${getButton('previous')}
${isPaused ? getButton('play') : getButton('pause')}
${getButton('next')}
</actions>\
`;
const toast = (content: string, isPaused: boolean) => `\
<toast>
<audio silent="true" />
<visual>
<binding template="ToastGeneric">
${content}
</binding>
</visual>
${getButtons(isPaused)}
</toast>`;
const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
<text id="1">${title}</text>
<text id="2">${artist}</text>\
`, isPaused ?? false);
const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"');
const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"');
const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, '');
const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\
<image id="1" src="${imgSrc}" name="Image" />
<text></text>
<group>
<subgroup>
<text hint-style="body">${songInfo.title}</text>
<text hint-style="captionSubtle">${songInfo.artist}</text>
</subgroup>
${xmlMoreData(songInfo)}
</group>\
`, songInfo.isPaused ?? false);
const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\
<subgroup hint-textStacking="bottom">
${album
? `<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)}</text>
</subgroup>\
`;
const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
<text></text>
<group>
<subgroup hint-weight="1" hint-textStacking="center">
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
</subgroup>
</group>
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
`, isPaused ?? false);
const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
<image id="1" src="${imgSrc}" name="Image" />
<text></text>
<group>
<subgroup hint-weight="1" hint-textStacking="center">
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
</subgroup>
</group>\
`, isPaused ?? false);
const titleFontPicker = (title: string) => {
if (title.length <= 13) {
return 'Header';
}
if (title.length <= 22) {
return 'Subheader';
}
if (title.length <= 26) {
return 'Title';
}
return 'Subtitle';
};

View File

@ -0,0 +1,93 @@
import is from 'electron-is';
import { BrowserWindow, MenuItem } from 'electron';
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
import config from './config';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
const getMenu = (options: ConfigType<'notifications'>): MenuTemplate => {
if (is.linux()) {
return [
{
label: 'Notification Priority',
submenu: urgencyLevels.map((level) => ({
label: level.name,
type: 'radio',
checked: options.urgency === level.value,
click: () => config.set('urgency', level.value),
})),
}
];
} else if (is.windows()) {
return [
{
label: 'Interactive Notifications',
type: 'checkbox',
checked: options.interactive,
// Doesn't update until restart
click: (item: MenuItem) => config.setAndMaybeRestart('interactive', item.checked),
},
{
// Submenu with settings for interactive notifications (name shouldn't be too long)
label: 'Interactive Settings',
submenu: [
{
label: 'Open/Close on tray click',
type: 'checkbox',
checked: options.trayControls,
click: (item: MenuItem) => config.set('trayControls', item.checked),
},
{
label: 'Hide Button Text',
type: 'checkbox',
checked: options.hideButtonText,
click: (item: MenuItem) => config.set('hideButtonText', item.checked),
},
{
label: 'Refresh on Play/Pause',
type: 'checkbox',
checked: options.refreshOnPlayPause,
click: (item: MenuItem) => config.set('refreshOnPlayPause', item.checked),
},
],
},
{
label: 'Style',
submenu: getToastStyleMenuItems(options),
},
];
} else {
return [];
}
};
export default (_win: BrowserWindow, options: ConfigType<'notifications'>): MenuTemplate => [
...getMenu(options),
{
label: 'Show notification on unpause',
type: 'checkbox',
checked: options.unpauseNotification,
click: (item: MenuItem) => config.set('unpauseNotification', item.checked),
},
];
export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
const array = Array.from({ length: Object.keys(ToastStyles).length });
// ToastStyles index starts from 1
for (const [name, index] of Object.entries(ToastStyles)) {
array[index - 1] = {
label: snakeToCamel(name),
type: 'radio',
checked: options.toastStyle === index,
click: () => config.set('toastStyle', index),
} satisfies Electron.MenuItemConstructorOptions;
}
return array as Electron.MenuItemConstructorOptions[];
}

View File

@ -0,0 +1,88 @@
import path from 'node:path';
import fs from 'node:fs';
import { app, NativeImage } from 'electron';
import config from './config';
import { cache } from '../../providers/decorators';
import { SongInfo } from '../../providers/song-info';
import { getAssetsDirectoryLocation } from '../utils';
const defaultIcon = path.join(getAssetsDirectoryLocation(), 'youtube-music.png');
const userData = app.getPath('userData');
const temporaryIcon = path.join(userData, 'tempIcon.png');
const temporaryBanner = path.join(userData, 'tempBanner.png');
export const ToastStyles = {
logo: 1,
banner_centered_top: 2,
hero: 3,
banner_top_custom: 4,
banner_centered_bottom: 5,
banner_bottom: 6,
legacy: 7,
};
export const urgencyLevels = [
{ name: 'Low', value: 'low' },
{ name: 'Normal', value: 'normal' },
{ name: 'High', value: 'critical' },
];
const nativeImageToLogo = cache((nativeImage: NativeImage) => {
const temporaryImage = nativeImage.resize({ height: 256 });
const margin = Math.max(temporaryImage.getSize().width - 256, 0);
return temporaryImage.crop({
x: Math.round(margin / 2),
y: 0,
width: 256,
height: 256,
});
});
export const notificationImage = (songInfo: SongInfo) => {
if (!songInfo.image) {
return defaultIcon;
}
if (!config.get('interactive')) {
return nativeImageToLogo(songInfo.image);
}
switch (config.get('toastStyle')) {
case ToastStyles.logo:
case ToastStyles.legacy: {
return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon);
}
default: {
return saveImage(songInfo.image, temporaryBanner);
}
}
};
export const saveImage = cache((img: NativeImage, savePath: string) => {
try {
fs.writeFileSync(savePath, img.toPNG());
} catch (error: unknown) {
console.log(`Error writing song icon to disk:\n${String(error)}`);
return defaultIcon;
}
return savePath;
});
export const snakeToCamel = (string_: string) => string_.replaceAll(/([-_][a-z]|^[a-z])/g, (group) =>
group.toUpperCase()
.replace('-', ' ')
.replace('_', ' '),
);
export const secondsToMinutes = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const secondsLeft = seconds % 60;
return `${minutes}:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`;
};

View File

@ -0,0 +1,111 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils';
import { setOptions as setPluginOptions } from '../../config/plugins';
import type { ConfigType } from '../../config/dynamic';
let isInPiP = false;
let originalPosition: number[];
let originalSize: number[];
let originalFullScreen: boolean;
let originalMaximized: boolean;
let win: BrowserWindow;
type PiPOptions = ConfigType<'picture-in-picture'>;
let options: Partial<PiPOptions>;
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
const setLocalOptions = (_options: Partial<PiPOptions>) => {
options = { ...options, ..._options };
setPluginOptions('picture-in-picture', _options);
};
const togglePiP = () => {
isInPiP = !isInPiP;
setLocalOptions({ isInPiP });
if (isInPiP) {
originalFullScreen = win.isFullScreen();
if (originalFullScreen) {
win.setFullScreen(false);
}
originalMaximized = win.isMaximized();
if (originalMaximized) {
win.unmaximize();
}
originalPosition = win.getPosition();
originalSize = win.getSize();
win.webContents.on('before-input-event', blockShortcutsInPiP);
win.setMaximizable(false);
win.setFullScreenable(false);
win.webContents.send('pip-toggle', true);
app.dock?.hide();
win.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
});
app.dock?.show();
if (options.alwaysOnTop) {
win.setAlwaysOnTop(true, 'screen-saver', 1);
}
} else {
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
win.setMaximizable(true);
win.setFullScreenable(true);
win.webContents.send('pip-toggle', false);
win.setVisibleOnAllWorkspaces(false);
win.setAlwaysOnTop(false);
if (originalFullScreen) {
win.setFullScreen(true);
}
if (originalMaximized) {
win.maximize();
}
}
const [x, y] = isInPiP ? pipPosition() : originalPosition;
const [w, h] = isInPiP ? pipSize() : originalSize;
win.setPosition(x, y);
win.setSize(w, h);
win.setWindowButtonVisibility?.(!isInPiP);
};
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
const key = input.key.toLowerCase();
if (key === 'f') {
event.preventDefault();
} else if (key === 'escape') {
togglePiP();
event.preventDefault();
}
};
export default (_win: BrowserWindow, _options: PiPOptions) => {
options ??= _options;
win ??= _win;
setLocalOptions({ isInPiP });
injectCSS(win.webContents, style);
ipcMain.on('picture-in-picture', () => {
togglePiP();
});
};
export const setOptions = setLocalOptions;

View File

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

View File

@ -0,0 +1,12 @@
declare module 'keyboardevent-from-electron-accelerator' {
interface KeyboardEvent {
key?: string;
code?: string;
metaKey?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
}
export const toKeyEvent: (accelerator: string) => KeyboardEvent;
}

View File

@ -0,0 +1,14 @@
declare module 'keyboardevents-areequal' {
interface KeyboardEvent {
key?: string;
code?: string;
metaKey?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
}
const areEqual: (event1: KeyboardEvent, event2: KeyboardEvent) => boolean;
export default areEqual;
}

View File

@ -0,0 +1,75 @@
import prompt from 'custom-electron-prompt';
import { BrowserWindow } from 'electron';
import { setOptions } from './back';
import promptOptions from '../../providers/prompt-options';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [
{
label: 'Always on top',
type: 'checkbox',
checked: options.alwaysOnTop,
click(item) {
setOptions({ alwaysOnTop: item.checked });
win.setAlwaysOnTop(item.checked);
},
},
{
label: 'Save window position',
type: 'checkbox',
checked: options.savePosition,
click(item) {
setOptions({ savePosition: item.checked });
},
},
{
label: 'Save window size',
type: 'checkbox',
checked: options.saveSize,
click(item) {
setOptions({ saveSize: item.checked });
},
},
{
label: 'Hotkey',
type: 'checkbox',
checked: !!options.hotkey,
async click(item) {
const output = await prompt({
title: 'Picture in Picture Hotkey',
label: 'Choose a hotkey for toggling Picture in Picture',
type: 'keybind',
keybindOptions: [{
value: 'hotkey',
label: 'Hotkey',
default: options.hotkey,
}],
...promptOptions(),
}, win);
if (output) {
const { value, accelerator } = output[0];
setOptions({ [value]: accelerator });
item.checked = !!accelerator;
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
},
},
{
label: 'Use native PiP',
type: 'checkbox',
checked: options.useNativePiP,
click(item) {
setOptions({ useNativePiP: item.checked });
},
},
];

View File

@ -0,0 +1,43 @@
/* improve visibility of the player bar elements */
ytmusic-app-layout.pip ytmusic-player-bar svg,
ytmusic-app-layout.pip ytmusic-player-bar .time-info,
ytmusic-app-layout.pip ytmusic-player-bar yt-formatted-string,
ytmusic-app-layout.pip ytmusic-player-bar .yt-formatted-string {
filter: drop-shadow(2px 4px 6px black);
color: white !important;
fill: white !important;
}
/* improve the style of the player bar expanding menu */
ytmusic-app-layout.pip ytmusic-player-expanding-menu {
border-radius: 30px;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px) brightness(20%);
}
/* fix volumeHud position when both in-app-menu and PiP are active */
.cet-container ytmusic-app-layout.pip #volumeHud {
top: 22px !important;
}
/* make player-bar not draggable if in-app-menu is enabled */
.cet-container ytmusic-app-layout.pip ytmusic-player-bar {
-webkit-app-region: no-drag !important;
}
/* make player draggable if in-app-menu is enabled */
.cet-container ytmusic-app-layout.pip #player {
-webkit-app-region: drag !important;
}
/* remove info, thumbnail and menu from player-bar */
ytmusic-app-layout.pip ytmusic-player-bar .content-info-wrapper,
ytmusic-app-layout.pip ytmusic-player-bar .thumbnail-image-wrapper,
ytmusic-app-layout.pip ytmusic-player-bar ytmusic-menu-renderer {
display: none !important;
}
/* disable the video-toggle button when in PiP mode */
ytmusic-app-layout.pip .video-switch-button {
display: none !important;
}

View File

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

View File

@ -0,0 +1,117 @@
import sliderHTML from './templates/slider.html';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils';
import { singleton } from '../../providers/decorators';
function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector);
}
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 = $<HTMLVideoElement>('video');
if (videoElement) {
videoElement.playbackRate = playbackSpeed;
}
const playbackSpeedElement = $('#playback-speed-value');
if (playbackSpeedElement) {
playbackSpeedElement.innerHTML = String(playbackSpeed);
}
};
let menu: Element | null = null;
const setupSliderListener = singleton(() => {
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', (e) => {
playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
updatePlayBackSpeed();
});
});
const observePopupContainer = () => {
const observer = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
}
if (
menu &&
(menu.parentElement as HTMLElement & { eventSink_: Element | null })
?.eventSink_
?.matches('ytmusic-menu-renderer.ytmusic-player-bar')&& !menu.contains(slider)
) {
menu.prepend(slider);
setupSliderListener();
}
});
const popupContainer = $('ytmusic-popup-container');
if (popupContainer) {
observer.observe(popupContainer, {
childList: true,
subtree: true,
});
}
};
const observeVideo = () => {
const video = $<HTMLVideoElement>('video');
if (video) {
video.addEventListener('ratechange', forcePlaybackRate);
video.addEventListener('srcChanged', forcePlaybackRate);
}
};
const setupWheelListener = () => {
slider.addEventListener('wheel', (e) => {
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 = $<HTMLElement & { value: number }>('#playback-speed-slider');
if (playbackSpeedSilder) {
playbackSpeedSilder.value = playbackSpeed;
}
});
};
function forcePlaybackRate(e: Event) {
if (e.target instanceof HTMLVideoElement) {
const videoElement = e.target;
if (videoElement.playbackRate !== playbackSpeed) {
videoElement.playbackRate = playbackSpeed;
}
}
}
export default () => {
document.addEventListener('apiLoaded', () => {
observePopupContainer();
observeVideo();
setupWheelListener();
}, { once: true, passive: true });
};

View File

@ -0,0 +1,93 @@
<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"
>
Speed (<span id="playback-speed-value">1</span>)
</div>
</div>
</div>

View File

@ -0,0 +1,28 @@
import { globalShortcut, BrowserWindow } from 'electron';
import volumeHudStyle from './volume-hud.css';
import { injectCSS } from '../utils';
import type { ConfigType } from '../../config/dynamic';
/*
This is used to determine if plugin is actually active
(not if it's only enabled in options)
*/
let isEnabled = false;
export const enabled = () => isEnabled;
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => {
isEnabled = true;
injectCSS(win.webContents, volumeHudStyle);
if (options.globalShortcuts?.volumeUp) {
globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true));
}
if (options.globalShortcuts?.volumeDown) {
globalShortcut.register((options.globalShortcuts.volumeDown), () => win.webContents.send('changeVolume', false));
}
};

View File

@ -0,0 +1,270 @@
import { ipcRenderer } from 'electron';
import { setOptions, setMenuOptions, isEnabled } from '../../config/plugins';
import { debounce } from '../../providers/decorators';
import { YoutubePlayer } from '../../types/youtube-player';
import type { ConfigType } from '../../config/dynamic';
function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector);
}
let api: YoutubePlayer;
let options: ConfigType<'precise-volume'>;
export default (_options: ConfigType<'precise-volume'>) => {
options = _options;
document.addEventListener('apiLoaded', (e) => {
api = e.detail;
ipcRenderer.on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease));
ipcRenderer.on('setVolume', (_, value: number) => setVolume(value));
firstRun();
}, { once: true, passive: true });
};
// Without this function it would rewrite config 20 time when volume change by 20
const writeOptions = debounce(() => {
setOptions('precise-volume', options);
}, 1000);
export const moveVolumeHud = debounce((showVideo: boolean) => {
const volumeHud = $<HTMLElement>('#volumeHud');
if (!volumeHud) {
return;
}
volumeHud.style.top = showVideo
? `${($('ytmusic-player')!.clientHeight - $('video')!.clientHeight) / 2}px`
: '0';
}, 250);
const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
volumeHud.style.opacity = '0';
}, 2000);
const hideVolumeSlider = debounce((slider: HTMLElement) => {
slider.classList.remove('on-hover');
}, 2500);
/** Restore saved volume and setup tooltip */
function firstRun() {
if (typeof options.savedVolume === 'number') {
// Set saved volume as tooltip
setTooltip(options.savedVolume);
if (api.getVolume() !== options.savedVolume) {
setVolume(options.savedVolume);
}
}
setupPlaybar();
setupLocalArrowShortcuts();
// Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue
const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none';
injectVolumeHud(noVid);
if (!noVid) {
setupVideoPlayerOnwheel();
if (!isEnabled('video-toggle')) {
// Video-toggle handles hud positioning on its own
const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV';
$('video')?.addEventListener('srcChanged', () => moveVolumeHud(videoMode()));
}
}
// Change options from renderer to keep sync
ipcRenderer.on('setOptions', (_event, newOptions = {}) => {
Object.assign(options, newOptions);
setMenuOptions('precise-volume', options);
});
}
function injectVolumeHud(noVid: boolean) {
if (noVid) {
const position = 'top: 18px; right: 60px;';
const mainStyle = 'font-size: xx-large;';
$('.center-content.ytmusic-nav-bar')?.insertAdjacentHTML(
'beforeend',
`<span id="volumeHud" style="${position + mainStyle}"></span>`,
);
} else {
const position = 'top: 10px; left: 10px;';
const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;';
$('#song-video')?.insertAdjacentHTML(
'afterend',
`<span id="volumeHud" style="${position + mainStyle}"></span>`,
);
}
}
function showVolumeHud(volume: number) {
const volumeHud = $<HTMLElement>('#volumeHud');
if (!volumeHud) {
return;
}
volumeHud.textContent = `${volume}%`;
volumeHud.style.opacity = '1';
hideVolumeHud(volumeHud);
}
/** Add onwheel event to video player */
function setupVideoPlayerOnwheel() {
const panel = $<HTMLElement>('#main-panel');
if (!panel) return;
panel.addEventListener('wheel', (event) => {
event.preventDefault();
// Event.deltaY < 0 means wheel-up
changeVolume(event.deltaY < 0);
});
}
function saveVolume(volume: number) {
options.savedVolume = volume;
writeOptions();
}
/** Add onwheel event to play bar and also track if play bar is hovered */
function setupPlaybar() {
const playerbar = $<HTMLElement>('ytmusic-player-bar');
if (!playerbar) return;
playerbar.addEventListener('wheel', (event) => {
event.preventDefault();
// Event.deltaY < 0 means wheel-up
changeVolume(event.deltaY < 0);
});
// Keep track of mouse position for showVolumeSlider()
playerbar.addEventListener('mouseenter', () => {
playerbar.classList.add('on-hover');
});
playerbar.addEventListener('mouseleave', () => {
playerbar.classList.remove('on-hover');
});
setupSliderObserver();
}
/** Save volume + Update the volume tooltip when volume-slider is manually changed */
function setupSliderObserver() {
const sliderObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.target instanceof HTMLInputElement) {
// This checks that volume-slider was manually set
const target = mutation.target;
const targetValueNumeric = Number(target.value);
if (mutation.oldValue !== target.value
&& (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - targetValueNumeric) > 4)) {
// Diff>4 means it was manually set
setTooltip(targetValueNumeric);
saveVolume(targetValueNumeric);
}
}
}
});
const slider = $('#volume-slider');
if (!slider) return;
// Observing only changes in 'value' of volume-slider
sliderObserver.observe(slider, {
attributeFilter: ['value'],
attributeOldValue: true,
});
}
function setVolume(value: number) {
api.setVolume(value);
// Save the new volume
saveVolume(value);
// Change slider position (important)
updateVolumeSlider();
// Change tooltips to new value
setTooltip(value);
// Show volume slider
showVolumeSlider();
// Show volume HUD
showVolumeHud(value);
}
/** If (toIncrease = false) then volume decrease */
function changeVolume(toIncrease: boolean) {
// Apply volume change if valid
const steps = Number(options.steps || 1);
setVolume(toIncrease
? Math.min(api.getVolume() + steps, 100)
: Math.max(api.getVolume() - steps, 0));
}
function updateVolumeSlider() {
const savedVolume = options.savedVolume ?? 0;
// Slider value automatically rounds to multiples of 5
for (const slider of ['#volume-slider', '#expand-volume-slider']) {
const silderElement = $<HTMLInputElement>(slider);
if (silderElement) {
silderElement.value = String(savedVolume > 0 && savedVolume < 5 ? 5 : savedVolume);
}
}
}
function showVolumeSlider() {
const slider = $<HTMLElement>('#volume-slider');
if (!slider) return;
// This class display the volume slider if not in minimized mode
slider.classList.add('on-hover');
hideVolumeSlider(slider);
}
// Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small)
const tooltipTargets = [
'#volume-slider',
'tp-yt-paper-icon-button.volume',
'#expand-volume-slider',
'#expand-volume',
];
function setTooltip(volume: number) {
for (const target of tooltipTargets) {
const tooltipTargetElement = $<HTMLElement>(target);
if (tooltipTargetElement) {
tooltipTargetElement.title = `${volume}%`;
}
}
}
function setupLocalArrowShortcuts() {
if (options.arrowsShortcut) {
window.addEventListener('keydown', (event) => {
if ($<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened) {
return;
}
switch (event.code) {
case 'ArrowUp': {
event.preventDefault();
changeVolume(true);
break;
}
case 'ArrowDown': {
event.preventDefault();
changeVolume(false);
break;
}
}
});
}
}

View File

@ -0,0 +1,94 @@
import prompt, { KeybindOptions } from 'custom-electron-prompt';
import { BrowserWindow, MenuItem } from 'electron';
import { enabled } from './back';
import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
function changeOptions(changedOptions: Partial<ConfigType<'precise-volume'>>, options: ConfigType<'precise-volume'>, win: BrowserWindow) {
for (const option in changedOptions) {
// HACK: Weird TypeScript error
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
}
// Dynamically change setting if plugin is enabled
if (enabled()) {
win.webContents.send('setOptions', changedOptions);
} else { // Fallback to usual method if disabled
setMenuOptions('precise-volume', options);
}
}
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>): MenuTemplate => [
{
label: 'Local Arrowkeys Controls',
type: 'checkbox',
checked: Boolean(options.arrowsShortcut),
click(item) {
changeOptions({ arrowsShortcut: item.checked }, options, win);
},
},
{
label: 'Global Hotkeys',
type: 'checkbox',
checked: Boolean(options.globalShortcuts?.volumeUp ?? options.globalShortcuts?.volumeDown),
click: (item) => promptGlobalShortcuts(win, options, item),
},
{
label: 'Set Custom Volume Steps',
click: () => promptVolumeSteps(win, options),
},
];
// Helper function for globalShortcuts prompt
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined });
async function promptVolumeSteps(win: BrowserWindow, options: ConfigType<'precise-volume'>) {
const output = await prompt({
title: 'Volume Steps',
label: 'Choose Volume Increase/Decrease Steps',
value: options.steps || 1,
type: 'counter',
counterOptions: { minimum: 0, maximum: 100, multiFire: true },
width: 380,
...promptOptions(),
}, win);
if (output || output === 0) { // 0 is somewhat valid
changeOptions({ steps: output }, options, win);
}
}
async function promptGlobalShortcuts(win: BrowserWindow, options: ConfigType<'precise-volume'>, item: MenuItem) {
const output = await prompt({
title: 'Global Volume Keybinds',
label: 'Choose Global Volume Keybinds:',
type: 'keybind',
keybindOptions: [
kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp),
kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown),
],
...promptOptions(),
}, win);
if (output) {
const newGlobalShortcuts: {
volumeUp: string;
volumeDown: string;
} = { volumeUp: '', volumeDown: '' };
for (const { value, accelerator } of output) {
newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator;
}
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win);
item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown);
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
}

View File

@ -0,0 +1,41 @@
/* what */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import is from 'electron-is';
const ignored = {
id: ['volume-slider', 'expand-volume-slider'],
types: ['mousewheel', 'keydown', 'keyup'],
};
function overrideAddEventListener() {
// YO WHAT ARE YOU DOING NOW?!?!
// Save native addEventListener
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/unbound-method
Element.prototype._addEventListener = Element.prototype.addEventListener;
// Override addEventListener to Ignore specific events in volume-slider
Element.prototype.addEventListener = function (type: string, listener: (event: Event) => void, useCapture = false) {
if (!(
ignored.id.includes(this.id)
&& ignored.types.includes(type)
)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
(this as any)._addEventListener(type, listener, useCapture);
} else if (is.dev()) {
console.log(`Ignoring event: "${this.id}.${type}()"`);
}
};
}
export default () => {
overrideAddEventListener();
// Restore original function after finished loading to avoid keeping Element.prototype altered
window.addEventListener('load', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
Element.prototype.addEventListener = (Element.prototype as any)._addEventListener;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
(Element.prototype as any)._addEventListener = undefined;
}, { once: true });
};

View File

@ -0,0 +1,13 @@
#volumeHud {
z-index: 999;
position: absolute;
transition: opacity 0.6s;
pointer-events: none;
padding: 10px;
text-shadow: rgba(0, 0, 0, 0.5) 0px 0px 12px;
}
ytmusic-player[player-ui-state_="MINIPLAYER"] #volumeHud {
top: 0 !important;
}

View File

@ -0,0 +1,13 @@
import { ipcMain, dialog } from 'electron';
export default () => {
ipcMain.handle('qualityChanger', async (_, qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox({
type: 'question',
buttons: qualityLabels,
defaultId: currentIndex,
title: 'Choose Video Quality',
message: 'Choose Video Quality:',
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
cancelId: -1,
}));
};

Some files were not shown because too many files have changed in this diff Show More