mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 18:41:47 +00:00
QOL: Move source code under the src directory. (#1318)
This commit is contained in:
287
src/config/defaults.ts
Normal file
287
src/config/defaults.ts
Normal 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
241
src/config/dynamic.ts
Normal 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
53
src/config/index.ts
Normal 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
63
src/config/plugins.ts
Normal 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
154
src/config/store.ts
Normal 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
85
src/custom-electron-prompt.d.ts
vendored
Normal 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
50
src/error.html
Normal 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
639
src/index.ts
Normal 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
480
src/menu.ts
Normal 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
88
src/navigation.d.ts
vendored
Normal 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
1
src/plugins/adblocker/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/ad-blocker-engine.bin
|
||||
19
src/plugins/adblocker/back.ts
Normal file
19
src/plugins/adblocker/back.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
};
|
||||
4
src/plugins/adblocker/blocker-types.ts
Normal file
4
src/plugins/adblocker/blocker-types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const blockers = {
|
||||
WithBlocklists: 'With blocklists',
|
||||
InPlayer: 'In player',
|
||||
} as const;
|
||||
67
src/plugins/adblocker/blocker.ts
Normal file
67
src/plugins/adblocker/blocker.ts
Normal 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 };
|
||||
15
src/plugins/adblocker/config.ts
Normal file
15
src/plugins/adblocker/config.ts
Normal 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,
|
||||
});
|
||||
3
src/plugins/adblocker/inject-cliqz-preload.ts
Normal file
3
src/plugins/adblocker/inject-cliqz-preload.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default async () => {
|
||||
await import('@cliqz/adblocker-electron-preload');
|
||||
};
|
||||
3
src/plugins/adblocker/inject.d.ts
vendored
Normal file
3
src/plugins/adblocker/inject.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
const inject: () => void;
|
||||
|
||||
export default inject;
|
||||
435
src/plugins/adblocker/inject.js
Normal file
435
src/plugins/adblocker/inject.js
Normal 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);
|
||||
})();
|
||||
};
|
||||
21
src/plugins/adblocker/menu.ts
Normal file
21
src/plugins/adblocker/menu.ts
Normal 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);
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
};
|
||||
15
src/plugins/adblocker/preload.ts
Normal file
15
src/plugins/adblocker/preload.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
9
src/plugins/album-color-theme/back.ts
Normal file
9
src/plugins/album-color-theme/back.ts
Normal 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);
|
||||
};
|
||||
127
src/plugins/album-color-theme/front.ts
Normal file
127
src/plugins/album-color-theme/front.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
29
src/plugins/album-color-theme/style.css
Normal file
29
src/plugins/album-color-theme/style.css
Normal 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;
|
||||
}
|
||||
10
src/plugins/ambient-mode/back.ts
Normal file
10
src/plugins/ambient-mode/back.ts
Normal 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);
|
||||
};
|
||||
138
src/plugins/ambient-mode/front.ts
Normal file
138
src/plugins/ambient-mode/front.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
7
src/plugins/ambient-mode/style.css
Normal file
7
src/plugins/ambient-mode/style.css
Normal file
@ -0,0 +1,7 @@
|
||||
#song-video canvas.html5-blur-canvas{
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
filter: blur(100px);
|
||||
}
|
||||
17
src/plugins/audio-compressor/front.ts
Normal file
17
src/plugins/audio-compressor/front.ts
Normal 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,
|
||||
});
|
||||
9
src/plugins/blur-nav-bar/back.ts
Normal file
9
src/plugins/blur-nav-bar/back.ts
Normal 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);
|
||||
};
|
||||
10
src/plugins/blur-nav-bar/style.css
Normal file
10
src/plugins/blur-nav-bar/style.css
Normal 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;
|
||||
}
|
||||
4
src/plugins/bypass-age-restrictions/front.ts
Normal file
4
src/plugins/bypass-age-restrictions/front.ts
Normal 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');
|
||||
};
|
||||
4
src/plugins/bypass-age-restrictions/simple-youtube-age-restriction-bypass.d.ts
vendored
Normal file
4
src/plugins/bypass-age-restrictions/simple-youtube-age-restriction-bypass.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module 'simple-youtube-age-restriction-bypass' {
|
||||
const nothing: never;
|
||||
export default nothing;
|
||||
}
|
||||
19
src/plugins/captions-selector/back.ts
Normal file
19
src/plugins/captions-selector/back.ts
Normal 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,
|
||||
));
|
||||
};
|
||||
4
src/plugins/captions-selector/config.ts
Normal file
4
src/plugins/captions-selector/config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PluginConfig } from '../../config/dynamic';
|
||||
|
||||
const config = new PluginConfig('captions-selector', { enableFront: true });
|
||||
export default config;
|
||||
101
src/plugins/captions-selector/front.ts
Normal file
101
src/plugins/captions-selector/front.ts
Normal 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
22
src/plugins/captions-selector/menu.ts
Normal file
22
src/plugins/captions-selector/menu.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
10
src/plugins/compact-sidebar/front.ts
Normal file
10
src/plugins/compact-sidebar/front.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
11
src/plugins/crossfade/back.ts
Normal file
11
src/plugins/crossfade/back.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
4
src/plugins/crossfade/config.ts
Normal file
4
src/plugins/crossfade/config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PluginConfig } from '../../config/dynamic';
|
||||
|
||||
const config = new PluginConfig('crossfade', { enableFront: true });
|
||||
export default config;
|
||||
395
src/plugins/crossfade/fader.ts
Normal file
395
src/plugins/crossfade/fader.ts
Normal 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
|
||||
};
|
||||
164
src/plugins/crossfade/front.ts
Normal file
164
src/plugins/crossfade/front.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
86
src/plugins/crossfade/menu.ts
Normal file
86
src/plugins/crossfade/menu.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
23
src/plugins/disable-autoplay/front.ts
Normal file
23
src/plugins/disable-autoplay/front.ts
Normal 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 });
|
||||
};
|
||||
20
src/plugins/disable-autoplay/menu.ts
Normal file
20
src/plugins/disable-autoplay/menu.ts
Normal 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
228
src/plugins/discord/back.ts
Normal 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;
|
||||
98
src/plugins/discord/menu.ts
Normal file
98
src/plugins/discord/menu.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
634
src/plugins/downloader/back.ts
Normal file
634
src/plugins/downloader/back.ts
Normal 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');
|
||||
};
|
||||
4
src/plugins/downloader/config.ts
Normal file
4
src/plugins/downloader/config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PluginConfig } from '../../config/dynamic';
|
||||
|
||||
const config = new PluginConfig('downloader');
|
||||
export default config;
|
||||
83
src/plugins/downloader/front.ts
Normal file
83
src/plugins/downloader/front.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
};
|
||||
46
src/plugins/downloader/menu.ts
Normal file
46
src/plugins/downloader/menu.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
];
|
||||
21
src/plugins/downloader/style.css
Normal file
21
src/plugins/downloader/style.css
Normal 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);
|
||||
}
|
||||
45
src/plugins/downloader/templates/download.html
Normal file
45
src/plugins/downloader/templates/download.html
Normal 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>
|
||||
116
src/plugins/downloader/types.ts
Normal file
116
src/plugins/downloader/types.ts
Normal 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: '' },
|
||||
];
|
||||
30
src/plugins/downloader/utils.ts
Normal file
30
src/plugins/downloader/utils.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
45
src/plugins/exponential-volume/front.ts
Normal file
45
src/plugins/exponential-volume/front.ts
Normal 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,
|
||||
});
|
||||
3
src/plugins/in-app-menu/assets/close.svg
Normal file
3
src/plugins/in-app-menu/assets/close.svg
Normal 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 |
3
src/plugins/in-app-menu/assets/maximize.svg
Normal file
3
src/plugins/in-app-menu/assets/maximize.svg
Normal 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 |
3
src/plugins/in-app-menu/assets/menu.svg
Normal file
3
src/plugins/in-app-menu/assets/menu.svg
Normal 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 |
3
src/plugins/in-app-menu/assets/minimize.svg
Normal file
3
src/plugins/in-app-menu/assets/minimize.svg
Normal 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 |
3
src/plugins/in-app-menu/assets/unmaximize.svg
Normal file
3
src/plugins/in-app-menu/assets/unmaximize.svg
Normal 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 |
67
src/plugins/in-app-menu/back.ts
Normal file
67
src/plugins/in-app-menu/back.ts
Normal 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'));
|
||||
};
|
||||
163
src/plugins/in-app-menu/front.ts
Normal file
163
src/plugins/in-app-menu/front.ts
Normal 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 });
|
||||
};
|
||||
10
src/plugins/in-app-menu/menu/icons.ts
Normal file
10
src/plugins/in-app-menu/menu/icons.ts
Normal 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;
|
||||
134
src/plugins/in-app-menu/menu/panel.ts
Normal file
134
src/plugins/in-app-menu/menu/panel.ts
Normal 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;
|
||||
};
|
||||
180
src/plugins/in-app-menu/titlebar.css
Normal file
180
src/plugins/in-app-menu/titlebar.css
Normal 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
203
src/plugins/last-fm/back.ts
Normal 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;
|
||||
82
src/plugins/lumiastream/back.ts
Normal file
82
src/plugins/lumiastream/back.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
124
src/plugins/lyrics-genius/back.ts
Normal file
124
src/plugins/lyrics-genius/back.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
108
src/plugins/lyrics-genius/front.ts
Normal file
108
src/plugins/lyrics-genius/front.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
19
src/plugins/lyrics-genius/menu.ts
Normal file
19
src/plugins/lyrics-genius/menu.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
];
|
||||
12
src/plugins/lyrics-genius/style.css
Normal file
12
src/plugins/lyrics-genius/style.css
Normal 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;
|
||||
}
|
||||
121
src/plugins/lyrics-genius/types.ts
Normal file
121
src/plugins/lyrics-genius/types.ts
Normal 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;
|
||||
}
|
||||
13
src/plugins/navigation/back.ts
Normal file
13
src/plugins/navigation/back.ts
Normal 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;
|
||||
20
src/plugins/navigation/front.ts
Normal file
20
src/plugins/navigation/front.ts
Normal 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;
|
||||
35
src/plugins/navigation/style.css
Normal file
35
src/plugins/navigation/style.css
Normal 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);
|
||||
}
|
||||
33
src/plugins/navigation/templates/back.html
Normal file
33
src/plugins/navigation/templates/back.html
Normal 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>
|
||||
35
src/plugins/navigation/templates/forward.html
Normal file
35
src/plugins/navigation/templates/forward.html
Normal 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>
|
||||
9
src/plugins/no-google-login/back.ts
Normal file
9
src/plugins/no-google-login/back.ts
Normal 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);
|
||||
};
|
||||
37
src/plugins/no-google-login/front.ts
Normal file
37
src/plugins/no-google-login/front.ts
Normal 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;
|
||||
6
src/plugins/no-google-login/style.css
Normal file
6
src/plugins/no-google-login/style.css
Normal 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;
|
||||
}
|
||||
51
src/plugins/notifications/back.ts
Normal file
51
src/plugins/notifications/back.ts
Normal 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();
|
||||
};
|
||||
5
src/plugins/notifications/config.ts
Normal file
5
src/plugins/notifications/config.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PluginConfig } from '../../config/dynamic';
|
||||
|
||||
const config = new PluginConfig('notifications');
|
||||
|
||||
export default config;
|
||||
257
src/plugins/notifications/interactive.ts
Normal file
257
src/plugins/notifications/interactive.ts
Normal 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';
|
||||
};
|
||||
93
src/plugins/notifications/menu.ts
Normal file
93
src/plugins/notifications/menu.ts
Normal 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[];
|
||||
}
|
||||
88
src/plugins/notifications/utils.ts
Normal file
88
src/plugins/notifications/utils.ts
Normal 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}`;
|
||||
};
|
||||
111
src/plugins/picture-in-picture/back.ts
Normal file
111
src/plugins/picture-in-picture/back.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import style from './style.css';
|
||||
|
||||
import { injectCSS } from '../utils';
|
||||
import { setOptions as setPluginOptions } from '../../config/plugins';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
let isInPiP = false;
|
||||
let originalPosition: number[];
|
||||
let originalSize: number[];
|
||||
let originalFullScreen: boolean;
|
||||
let originalMaximized: boolean;
|
||||
|
||||
let win: BrowserWindow;
|
||||
|
||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
||||
|
||||
let options: Partial<PiPOptions>;
|
||||
|
||||
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
|
||||
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
|
||||
|
||||
const setLocalOptions = (_options: Partial<PiPOptions>) => {
|
||||
options = { ...options, ..._options };
|
||||
setPluginOptions('picture-in-picture', _options);
|
||||
};
|
||||
|
||||
const togglePiP = () => {
|
||||
isInPiP = !isInPiP;
|
||||
setLocalOptions({ isInPiP });
|
||||
|
||||
if (isInPiP) {
|
||||
originalFullScreen = win.isFullScreen();
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(false);
|
||||
}
|
||||
|
||||
originalMaximized = win.isMaximized();
|
||||
if (originalMaximized) {
|
||||
win.unmaximize();
|
||||
}
|
||||
|
||||
originalPosition = win.getPosition();
|
||||
originalSize = win.getSize();
|
||||
|
||||
win.webContents.on('before-input-event', blockShortcutsInPiP);
|
||||
|
||||
win.setMaximizable(false);
|
||||
win.setFullScreenable(false);
|
||||
|
||||
win.webContents.send('pip-toggle', true);
|
||||
|
||||
app.dock?.hide();
|
||||
win.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
app.dock?.show();
|
||||
if (options.alwaysOnTop) {
|
||||
win.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
}
|
||||
} else {
|
||||
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
||||
win.setMaximizable(true);
|
||||
win.setFullScreenable(true);
|
||||
|
||||
win.webContents.send('pip-toggle', false);
|
||||
|
||||
win.setVisibleOnAllWorkspaces(false);
|
||||
win.setAlwaysOnTop(false);
|
||||
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(true);
|
||||
}
|
||||
|
||||
if (originalMaximized) {
|
||||
win.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
||||
const [w, h] = isInPiP ? pipSize() : originalSize;
|
||||
win.setPosition(x, y);
|
||||
win.setSize(w, h);
|
||||
|
||||
win.setWindowButtonVisibility?.(!isInPiP);
|
||||
};
|
||||
|
||||
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
||||
const key = input.key.toLowerCase();
|
||||
|
||||
if (key === 'f') {
|
||||
event.preventDefault();
|
||||
} else if (key === 'escape') {
|
||||
togglePiP();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
export default (_win: BrowserWindow, _options: PiPOptions) => {
|
||||
options ??= _options;
|
||||
win ??= _win;
|
||||
setLocalOptions({ isInPiP });
|
||||
injectCSS(win.webContents, style);
|
||||
ipcMain.on('picture-in-picture', () => {
|
||||
togglePiP();
|
||||
});
|
||||
};
|
||||
|
||||
export const setOptions = setLocalOptions;
|
||||
179
src/plugins/picture-in-picture/front.ts
Normal file
179
src/plugins/picture-in-picture/front.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
|
||||
import keyEventAreEqual from 'keyboardevents-areequal';
|
||||
|
||||
import pipHTML from './templates/picture-in-picture.html';
|
||||
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
|
||||
import { ElementFromHtml } from '../utils';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
||||
|
||||
function $<E extends Element = Element>(selector: string) {
|
||||
return document.querySelector<E>(selector);
|
||||
}
|
||||
|
||||
let useNativePiP = false;
|
||||
let menu: Element | null = null;
|
||||
const pipButton = ElementFromHtml(pipHTML);
|
||||
|
||||
// Will also clone
|
||||
function replaceButton(query: string, button: Element) {
|
||||
const svg = button.querySelector('#icon svg')?.cloneNode(true);
|
||||
if (svg) {
|
||||
button.replaceWith(button.cloneNode(true));
|
||||
button.remove();
|
||||
const newButton = $(query);
|
||||
if (newButton) {
|
||||
newButton.querySelector('#icon')?.append(svg);
|
||||
}
|
||||
return newButton;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cloneButton(query: string) {
|
||||
const button = $(query);
|
||||
if (button) {
|
||||
replaceButton(query, button);
|
||||
}
|
||||
return $(query);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
menu.contains(pipButton) ||
|
||||
!(menu.parentElement as (HTMLElement & { eventSink_: Element }) | null)
|
||||
?.eventSink_
|
||||
?.matches('ytmusic-menu-renderer.ytmusic-player-bar')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuUrl = $<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(pipButton);
|
||||
});
|
||||
|
||||
const togglePictureInPicture = async () => {
|
||||
if (useNativePiP) {
|
||||
const isInPiP = document.pictureInPictureElement !== null;
|
||||
const video = $<HTMLVideoElement>('video');
|
||||
const togglePiP = () =>
|
||||
isInPiP
|
||||
? document.exitPictureInPicture.call(document)
|
||||
: video?.requestPictureInPicture?.call(video);
|
||||
|
||||
try {
|
||||
await togglePiP();
|
||||
$<HTMLButtonElement>('#icon')?.click(); // Close the menu
|
||||
return true;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.send('picture-in-picture');
|
||||
return false;
|
||||
};
|
||||
// For UI (HTML)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
||||
(global as any).togglePictureInPicture = togglePictureInPicture;
|
||||
|
||||
const listenForToggle = () => {
|
||||
const originalExitButton = $<HTMLButtonElement>('.exit-fullscreen-button');
|
||||
const appLayout = $<HTMLElement>('ytmusic-app-layout');
|
||||
const expandMenu = $<HTMLElement>('#expanding-menu');
|
||||
const middleControls = $<HTMLButtonElement>('.middle-controls');
|
||||
const playerPage = $<HTMLElement & { playerPageOpen_: boolean }>('ytmusic-player-page');
|
||||
const togglePlayerPageButton = $<HTMLButtonElement>('.toggle-player-page-button');
|
||||
const fullScreenButton = $<HTMLButtonElement>('.fullscreen-button');
|
||||
const player = $<HTMLVideoElement & { onDoubleClick_: (() => void) | undefined }>('#player');
|
||||
const onPlayerDblClick = player?.onDoubleClick_;
|
||||
const mouseLeaveEventListener = () => middleControls?.click();
|
||||
|
||||
const titlebar = $<HTMLElement>('.cet-titlebar');
|
||||
|
||||
ipcRenderer.on('pip-toggle', (_, isPip: boolean) => {
|
||||
if (originalExitButton && player) {
|
||||
if (isPip) {
|
||||
replaceButton('.exit-fullscreen-button', originalExitButton)?.addEventListener('click', () => togglePictureInPicture());
|
||||
player.onDoubleClick_ = () => {
|
||||
};
|
||||
|
||||
expandMenu?.addEventListener('mouseleave', mouseLeaveEventListener);
|
||||
if (!playerPage?.playerPageOpen_) {
|
||||
togglePlayerPageButton?.click();
|
||||
}
|
||||
|
||||
fullScreenButton?.click();
|
||||
appLayout?.classList.add('pip');
|
||||
if (titlebar) {
|
||||
titlebar.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
$('.exit-fullscreen-button')?.replaceWith(originalExitButton);
|
||||
player.onDoubleClick_ = onPlayerDblClick;
|
||||
expandMenu?.removeEventListener('mouseleave', mouseLeaveEventListener);
|
||||
originalExitButton.click();
|
||||
appLayout?.classList.remove('pip');
|
||||
if (titlebar) {
|
||||
titlebar.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function observeMenu(options: PiPOptions) {
|
||||
useNativePiP = options.useNativePiP;
|
||||
document.addEventListener(
|
||||
'apiLoaded',
|
||||
() => {
|
||||
listenForToggle();
|
||||
|
||||
cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
|
||||
await togglePictureInPicture();
|
||||
setTimeout(() => $<HTMLButtonElement>('#player')?.click());
|
||||
});
|
||||
|
||||
// Allows easily closing the menu by programmatically clicking outside of it
|
||||
$('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click');
|
||||
// TODO: think about wether an additional button in songMenu is needed
|
||||
const popupContainer = $('ytmusic-popup-container');
|
||||
if (popupContainer) observer.observe(popupContainer, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
},
|
||||
{ once: true, passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
export default (options: PiPOptions) => {
|
||||
observeMenu(options);
|
||||
|
||||
if (options.hotkey) {
|
||||
const hotkeyEvent = toKeyEvent(options.hotkey);
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (
|
||||
keyEventAreEqual(event, hotkeyEvent)
|
||||
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
|
||||
) {
|
||||
togglePictureInPicture();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
12
src/plugins/picture-in-picture/keyboardevent-from-electron-accelerator.d.ts
vendored
Normal file
12
src/plugins/picture-in-picture/keyboardevent-from-electron-accelerator.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
declare module 'keyboardevent-from-electron-accelerator' {
|
||||
interface KeyboardEvent {
|
||||
key?: string;
|
||||
code?: string;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
|
||||
export const toKeyEvent: (accelerator: string) => KeyboardEvent;
|
||||
}
|
||||
14
src/plugins/picture-in-picture/keyboardevents-areequal.d.ts
vendored
Normal file
14
src/plugins/picture-in-picture/keyboardevents-areequal.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
declare module 'keyboardevents-areequal' {
|
||||
interface KeyboardEvent {
|
||||
key?: string;
|
||||
code?: string;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
|
||||
const areEqual: (event1: KeyboardEvent, event2: KeyboardEvent) => boolean;
|
||||
|
||||
export default areEqual;
|
||||
}
|
||||
75
src/plugins/picture-in-picture/menu.ts
Normal file
75
src/plugins/picture-in-picture/menu.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { setOptions } from './back';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
|
||||
import { MenuTemplate } from '../../menu';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [
|
||||
{
|
||||
label: 'Always on top',
|
||||
type: 'checkbox',
|
||||
checked: options.alwaysOnTop,
|
||||
click(item) {
|
||||
setOptions({ alwaysOnTop: item.checked });
|
||||
win.setAlwaysOnTop(item.checked);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Save window position',
|
||||
type: 'checkbox',
|
||||
checked: options.savePosition,
|
||||
click(item) {
|
||||
setOptions({ savePosition: item.checked });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Save window size',
|
||||
type: 'checkbox',
|
||||
checked: options.saveSize,
|
||||
click(item) {
|
||||
setOptions({ saveSize: item.checked });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hotkey',
|
||||
type: 'checkbox',
|
||||
checked: !!options.hotkey,
|
||||
async click(item) {
|
||||
const output = await prompt({
|
||||
title: 'Picture in Picture Hotkey',
|
||||
label: 'Choose a hotkey for toggling Picture in Picture',
|
||||
type: 'keybind',
|
||||
keybindOptions: [{
|
||||
value: 'hotkey',
|
||||
label: 'Hotkey',
|
||||
default: options.hotkey,
|
||||
}],
|
||||
...promptOptions(),
|
||||
}, win);
|
||||
|
||||
if (output) {
|
||||
const { value, accelerator } = output[0];
|
||||
setOptions({ [value]: accelerator });
|
||||
|
||||
item.checked = !!accelerator;
|
||||
} else {
|
||||
// Reset checkbox if prompt was canceled
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Use native PiP',
|
||||
type: 'checkbox',
|
||||
checked: options.useNativePiP,
|
||||
click(item) {
|
||||
setOptions({ useNativePiP: item.checked });
|
||||
},
|
||||
},
|
||||
];
|
||||
43
src/plugins/picture-in-picture/style.css
Normal file
43
src/plugins/picture-in-picture/style.css
Normal file
@ -0,0 +1,43 @@
|
||||
/* improve visibility of the player bar elements */
|
||||
ytmusic-app-layout.pip ytmusic-player-bar svg,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar .time-info,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar yt-formatted-string,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar .yt-formatted-string {
|
||||
filter: drop-shadow(2px 4px 6px black);
|
||||
color: white !important;
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
/* improve the style of the player bar expanding menu */
|
||||
ytmusic-app-layout.pip ytmusic-player-expanding-menu {
|
||||
border-radius: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px) brightness(20%);
|
||||
}
|
||||
|
||||
/* fix volumeHud position when both in-app-menu and PiP are active */
|
||||
.cet-container ytmusic-app-layout.pip #volumeHud {
|
||||
top: 22px !important;
|
||||
}
|
||||
|
||||
/* make player-bar not draggable if in-app-menu is enabled */
|
||||
.cet-container ytmusic-app-layout.pip ytmusic-player-bar {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
/* make player draggable if in-app-menu is enabled */
|
||||
.cet-container ytmusic-app-layout.pip #player {
|
||||
-webkit-app-region: drag !important;
|
||||
}
|
||||
|
||||
/* remove info, thumbnail and menu from player-bar */
|
||||
ytmusic-app-layout.pip ytmusic-player-bar .content-info-wrapper,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar .thumbnail-image-wrapper,
|
||||
ytmusic-app-layout.pip ytmusic-player-bar ytmusic-menu-renderer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* disable the video-toggle button when in PiP mode */
|
||||
ytmusic-app-layout.pip .video-switch-button {
|
||||
display: none !important;
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
<div
|
||||
aria-disabled="false"
|
||||
aria-selected="false"
|
||||
class="style-scope menu-item ytmusic-menu-popup-renderer"
|
||||
onclick="togglePictureInPicture()"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
|
||||
id="navigation-endpoint"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="icon menu-icon style-scope ytmusic-menu-navigation-item-renderer"
|
||||
>
|
||||
<svg
|
||||
id="Layer_1"
|
||||
style="enable-background: new 0 0 512 512"
|
||||
version="1.1"
|
||||
viewBox="0 0 512 512"
|
||||
x="0px"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
y="0px"
|
||||
>
|
||||
<style type="text/css">
|
||||
.st0 {
|
||||
fill: #aaaaaa;
|
||||
}
|
||||
</style>
|
||||
<g id="XMLID_6_">
|
||||
<path
|
||||
class="st0"
|
||||
d="M418.5,139.4H232.4v139.8h186.1V139.4z M464.8,46.7H46.3C20.5,46.7,0,68.1,0,93.1v325.9
|
||||
c0,25.8,21.4,46.3,46.3,46.3h419.4c25.8,0,46.3-20.5,46.3-46.3V93.1C512,67.2,490.6,46.7,464.8,46.7z M464.8,418.9H46.3V92.2h419.4
|
||||
v326.8H464.8z"
|
||||
id="XMLID_11_"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="text style-scope ytmusic-menu-navigation-item-renderer"
|
||||
id="ytmcustom-pip"
|
||||
>
|
||||
Picture in picture
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
117
src/plugins/playback-speed/front.ts
Normal file
117
src/plugins/playback-speed/front.ts
Normal 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 });
|
||||
};
|
||||
93
src/plugins/playback-speed/templates/slider.html
Normal file
93
src/plugins/playback-speed/templates/slider.html
Normal 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>
|
||||
28
src/plugins/precise-volume/back.ts
Normal file
28
src/plugins/precise-volume/back.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
270
src/plugins/precise-volume/front.ts
Normal file
270
src/plugins/precise-volume/front.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
94
src/plugins/precise-volume/menu.ts
Normal file
94
src/plugins/precise-volume/menu.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/plugins/precise-volume/preload.ts
Normal file
41
src/plugins/precise-volume/preload.ts
Normal 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 });
|
||||
};
|
||||
13
src/plugins/precise-volume/volume-hud.css
Normal file
13
src/plugins/precise-volume/volume-hud.css
Normal 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;
|
||||
}
|
||||
13
src/plugins/quality-changer/back.ts
Normal file
13
src/plugins/quality-changer/back.ts
Normal 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
Reference in New Issue
Block a user