mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 10:11:46 +00:00
Merge branch 'feat/new-plugin-system' into feat/refactor-plugin-system
This commit is contained in:
12
.eslintrc.js
12
.eslintrc.js
@ -26,7 +26,7 @@ module.exports = {
|
||||
'import/newline-after-import': 'error',
|
||||
'import/no-default-export': 'off',
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack', '^youtubei.js$'] }],
|
||||
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'] }],
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
@ -67,4 +67,14 @@ module.exports = {
|
||||
es6: true,
|
||||
},
|
||||
ignorePatterns: ['dist', 'node_modules'],
|
||||
root: true,
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts']
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {},
|
||||
exports: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,3 +12,4 @@ electron-builder.yml
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
.vite-inspect
|
||||
|
||||
@ -1,19 +1,27 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { defineConfig, defineViteConfig } from 'electron-vite';
|
||||
import builtinModules from 'builtin-modules';
|
||||
import viteResolve from 'vite-plugin-resolve';
|
||||
import Inspect from 'vite-plugin-inspect';
|
||||
|
||||
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-virtual-module-generator';
|
||||
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer';
|
||||
import pluginLoader from './vite-plugins/plugin-loader';
|
||||
|
||||
import type { UserConfig } from 'vite';
|
||||
|
||||
const resolveAlias = {
|
||||
'@': resolve(__dirname, './src'),
|
||||
'@assets': resolve(__dirname, './assets'),
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
main: defineViteConfig(({ mode }) => {
|
||||
const commonConfig: UserConfig = {
|
||||
plugins: [
|
||||
pluginLoader('backend'),
|
||||
viteResolve({
|
||||
'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'),
|
||||
'virtual:MainPlugins': pluginVirtualModuleGenerator('main'),
|
||||
'virtual:MenuPlugins': pluginVirtualModuleGenerator('menu'),
|
||||
'virtual:plugins': pluginVirtualModuleGenerator('main'),
|
||||
}),
|
||||
],
|
||||
publicDir: 'assets',
|
||||
@ -31,9 +39,15 @@ export default defineConfig({
|
||||
input: './src/index.ts',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: resolveAlias,
|
||||
},
|
||||
};
|
||||
|
||||
if (mode === 'development') {
|
||||
commonConfig.plugins?.push(
|
||||
Inspect({ build: true, outputDir: '.vite-inspect/backend' }),
|
||||
);
|
||||
return commonConfig;
|
||||
}
|
||||
|
||||
@ -49,9 +63,9 @@ export default defineConfig({
|
||||
preload: defineViteConfig(({ mode }) => {
|
||||
const commonConfig: UserConfig = {
|
||||
plugins: [
|
||||
pluginLoader('preload'),
|
||||
viteResolve({
|
||||
'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'),
|
||||
'virtual:PreloadPlugins': pluginVirtualModuleGenerator('preload'),
|
||||
'virtual:plugins': pluginVirtualModuleGenerator('preload'),
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
@ -66,11 +80,17 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
||||
input: './src/preload.ts',
|
||||
}
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: resolveAlias,
|
||||
},
|
||||
};
|
||||
|
||||
if (mode === 'development') {
|
||||
commonConfig.plugins?.push(
|
||||
Inspect({ build: true, outputDir: '.vite-inspect/preload' }),
|
||||
);
|
||||
return commonConfig;
|
||||
}
|
||||
|
||||
@ -86,9 +106,9 @@ export default defineConfig({
|
||||
renderer: defineViteConfig(({ mode }) => {
|
||||
const commonConfig: UserConfig = {
|
||||
plugins: [
|
||||
pluginLoader('renderer'),
|
||||
viteResolve({
|
||||
'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'),
|
||||
'virtual:RendererPlugins': pluginVirtualModuleGenerator('renderer'),
|
||||
'virtual:plugins': pluginVirtualModuleGenerator('renderer'),
|
||||
}),
|
||||
],
|
||||
root: './src/',
|
||||
@ -107,9 +127,15 @@ export default defineConfig({
|
||||
input: './src/index.html',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: resolveAlias,
|
||||
},
|
||||
};
|
||||
|
||||
if (mode === 'development') {
|
||||
commonConfig.plugins?.push(
|
||||
Inspect({ build: true, outputDir: '.vite-inspect/renderer' }),
|
||||
);
|
||||
return commonConfig;
|
||||
}
|
||||
|
||||
|
||||
14
package.json
14
package.json
@ -94,11 +94,12 @@
|
||||
"test": "playwright test",
|
||||
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
|
||||
"build": "electron-vite build",
|
||||
"vite:inspect": "yarpm-pnpm run clean && electron-vite build --mode development && yarpm-pnpm exec serve .vite-inspect",
|
||||
"start": "electron-vite preview",
|
||||
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start",
|
||||
"dev": "electron-vite dev --watch",
|
||||
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run dev",
|
||||
"clean": "del-cli dist && del-cli pack",
|
||||
"clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
|
||||
"dist": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win --mac --linux -p never",
|
||||
"dist:linux": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --linux -p never",
|
||||
"dist:mac": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac dmg:x64 -p never",
|
||||
@ -136,6 +137,8 @@
|
||||
"dependencies": {
|
||||
"@cliqz/adblocker-electron": "1.26.11",
|
||||
"@cliqz/adblocker-electron-preload": "1.26.11",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@electron/remote": "2.1.0",
|
||||
"@ffmpeg.wasm/core-mt": "0.12.0",
|
||||
"@ffmpeg.wasm/main": "0.12.0",
|
||||
"@foobar404/wave": "2.0.4",
|
||||
@ -164,7 +167,9 @@
|
||||
"keyboardevents-areequal": "0.2.2",
|
||||
"node-html-parser": "6.1.11",
|
||||
"node-id3": "0.2.6",
|
||||
"serve": "^14.2.1",
|
||||
"simple-youtube-age-restriction-bypass": "git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8",
|
||||
"ts-morph": "^20.0.0",
|
||||
"vudio": "2.1.1",
|
||||
"x11": "2.3.0",
|
||||
"youtubei.js": "7.0.0"
|
||||
@ -175,7 +180,7 @@
|
||||
"@types/electron-localshortcut": "3.1.3",
|
||||
"@types/howler": "2.2.11",
|
||||
"@types/html-to-text": "9.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "6.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.12.0",
|
||||
"bufferutil": "4.0.8",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"cross-env": "7.0.3",
|
||||
@ -184,7 +189,9 @@
|
||||
"electron-builder": "24.6.4",
|
||||
"electron-devtools-installer": "3.2.0",
|
||||
"electron-vite": "1.0.28",
|
||||
"eslint": "8.53.0",
|
||||
"eslint": "8.54.0",
|
||||
"eslint-import-resolver-exports": "1.0.0-beta.5",
|
||||
"eslint-import-resolver-typescript": "3.6.1",
|
||||
"eslint-plugin-import": "2.29.0",
|
||||
"eslint-plugin-prettier": "5.0.1",
|
||||
"glob": "10.3.10",
|
||||
@ -194,6 +201,7 @@
|
||||
"typescript": "5.2.2",
|
||||
"utf-8-validate": "6.0.3",
|
||||
"vite": "4.5.0",
|
||||
"vite-plugin-inspect": "^0.7.42",
|
||||
"vite-plugin-resolve": "2.5.1",
|
||||
"ws": "8.14.2",
|
||||
"yarpm": "1.2.0"
|
||||
|
||||
643
pnpm-lock.yaml
generated
643
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,10 +2,11 @@ import Store from 'electron-store';
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
|
||||
import defaultConfig from './defaults';
|
||||
import plugins from './plugins';
|
||||
import store from './store';
|
||||
|
||||
import { restart } from '../providers/app-controls';
|
||||
import store from './store';
|
||||
import plugins from './plugins';
|
||||
|
||||
import { restart } from '@/providers/app-controls';
|
||||
|
||||
const set = (key: string, value: unknown) => {
|
||||
store.set(key, value);
|
||||
@ -15,7 +16,7 @@ const setPartial = (key: string, value: object) => {
|
||||
store.set(key, newValue);
|
||||
};
|
||||
|
||||
function setMenuOption(key: string, value: unknown) {
|
||||
function setMenuOption(key: string, value: unknown) {
|
||||
set(key, value);
|
||||
if (store.get('options.restartOnConfigChanges')) {
|
||||
restart();
|
||||
@ -24,24 +25,55 @@ function setMenuOption(key: string, value: unknown) {
|
||||
|
||||
// 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]>>
|
||||
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
|
||||
}[keyof T] : ''
|
||||
: 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>;
|
||||
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,
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
|
||||
import store from './store';
|
||||
|
||||
import { restart } from '../providers/app-controls';
|
||||
import { restart } from '@/providers/app-controls';
|
||||
|
||||
import type { PluginBaseConfig } from '../plugins/utils/builder';
|
||||
import type { PluginConfig } from '@/types/plugins';
|
||||
|
||||
export function getPlugins() {
|
||||
return store.get('plugins') as Record<string, PluginBaseConfig>;
|
||||
return store.get('plugins') as Record<string, PluginConfig>;
|
||||
}
|
||||
|
||||
export function isEnabled(plugin: string) {
|
||||
const pluginConfig = (store.get('plugins') as Record<string, PluginBaseConfig>)[plugin];
|
||||
const pluginConfig = (store.get('plugins') as Record<string, PluginConfig>)[plugin];
|
||||
return pluginConfig !== undefined && pluginConfig.enabled;
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import Store from 'electron-store';
|
||||
import Conf from 'conf';
|
||||
|
||||
import { pluginBuilders } from 'virtual:PluginBuilders';
|
||||
import { allPlugins } from 'virtual:plugins';
|
||||
|
||||
import defaults from './defaults';
|
||||
|
||||
import { DefaultPresetList, type Preset } from '../plugins/downloader/types';
|
||||
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
|
||||
|
||||
const setDefaultPluginOptions = (store: Conf<Record<string, unknown>>, plugin: keyof typeof pluginBuilders) => {
|
||||
const setDefaultPluginOptions = (
|
||||
store: Conf<Record<string, unknown>>,
|
||||
plugin: string,
|
||||
) => {
|
||||
if (!store.get(`plugins.${plugin}`)) {
|
||||
store.set(`plugins.${plugin}`, pluginBuilders[plugin].config);
|
||||
store.set(`plugins.${plugin}`, allPlugins[plugin].config);
|
||||
}
|
||||
};
|
||||
|
||||
@ -22,19 +25,24 @@ const migrations = {
|
||||
}
|
||||
},
|
||||
'>=2.1.0'(store: Conf<Record<string, unknown>>) {
|
||||
const originalPreset = store.get('plugins.downloader.preset') as string | undefined;
|
||||
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,
|
||||
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[] ?? [],
|
||||
ffmpegArgs:
|
||||
(store.get('plugins.downloader.ffmpegArgs') as string[]) ?? [],
|
||||
} satisfies Preset);
|
||||
}
|
||||
store.delete('plugins.downloader.preset');
|
||||
@ -47,7 +55,7 @@ const migrations = {
|
||||
if (store.get('plugins.notifications.toastStyle') === undefined) {
|
||||
const pluginOptions = store.get('plugins.notifications') || {};
|
||||
store.set('plugins.notifications', {
|
||||
...pluginBuilders.notifications.config,
|
||||
...allPlugins.notifications.config,
|
||||
...pluginOptions,
|
||||
});
|
||||
}
|
||||
@ -82,10 +90,14 @@ const migrations = {
|
||||
}
|
||||
},
|
||||
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
|
||||
const options = store.get('plugins.shortcuts') as Record<string, {
|
||||
action: string;
|
||||
shortcut: unknown;
|
||||
}[] | 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])) {
|
||||
@ -151,12 +163,7 @@ const migrations = {
|
||||
export default new Store({
|
||||
defaults: {
|
||||
...defaults,
|
||||
plugins: Object
|
||||
.entries(pluginBuilders)
|
||||
.reduce((prev, [id, builder]) => ({
|
||||
...prev,
|
||||
[id]: (builder as PluginBuilderList[keyof PluginBuilderList]).config,
|
||||
}), {}),
|
||||
// README: 'plugin' uses deepmerge to populate the default values, so it is not necessary to include it here
|
||||
},
|
||||
clearInvalidConfig: false,
|
||||
migrations,
|
||||
|
||||
355
src/index.ts
355
src/index.ts
@ -2,8 +2,19 @@ import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
|
||||
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request';
|
||||
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';
|
||||
@ -12,30 +23,32 @@ import { parse } from 'node-html-parser';
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
import { mainPlugins } from 'virtual:MainPlugins';
|
||||
import { pluginBuilders } from 'virtual:PluginBuilders';
|
||||
import { allPlugins, mainPlugins } from 'virtual:plugins';
|
||||
|
||||
import config from './config';
|
||||
import config from '@/config';
|
||||
|
||||
import { refreshMenu, setApplicationMenu } from './menu';
|
||||
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils/main';
|
||||
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 { refreshMenu, setApplicationMenu } from '@/menu';
|
||||
import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main';
|
||||
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 youtubeMusicCSS from './youtube-music.css?inline';
|
||||
import youtubeMusicCSS from '@/youtube-music.css?inline';
|
||||
|
||||
import {
|
||||
forceLoadMainPlugin,
|
||||
forceUnloadMainPlugin,
|
||||
getAllLoadedMainPlugins,
|
||||
loadAllMainPlugins,
|
||||
registerMainPlugin
|
||||
} from './loader/main';
|
||||
import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder';
|
||||
} from '@/loader/main';
|
||||
|
||||
import type { PluginConfig } from '@/types/plugins';
|
||||
|
||||
// Catch errors and log them
|
||||
unhandled({
|
||||
@ -57,7 +70,10 @@ if (!gotTheLock) {
|
||||
|
||||
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
|
||||
// OverlayScrollbar: Required for overlay scrollbars
|
||||
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer');
|
||||
app.commandLine.appendSwitch(
|
||||
'enable-features',
|
||||
'OverlayScrollbar,SharedArrayBuffer',
|
||||
);
|
||||
if (config.get('options.disableHardwareAcceleration')) {
|
||||
if (is.dev()) {
|
||||
console.log('Disabling hardware acceleration');
|
||||
@ -95,42 +111,59 @@ function onClosed() {
|
||||
|
||||
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
|
||||
|
||||
|
||||
const initHook = (win: BrowserWindow) => {
|
||||
ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as PluginBuilderList[typeof id]['config']);
|
||||
ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj));
|
||||
ipcMain.handle(
|
||||
'get-config',
|
||||
(_, id: string) =>
|
||||
deepmerge(
|
||||
allPlugins[id].config,
|
||||
config.get(`plugins.${id}`) ?? {},
|
||||
) as PluginConfig,
|
||||
);
|
||||
ipcMain.handle('set-config', (_, name: string, obj: object) =>
|
||||
config.setPartial(`plugins.${name}`, obj),
|
||||
);
|
||||
|
||||
config.watch((newValue, oldValue) => {
|
||||
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<string, unknown>;
|
||||
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<string, unknown>;
|
||||
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => {
|
||||
const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig);
|
||||
|
||||
if (!isEqual) {
|
||||
const oldConfig = oldPluginConfigList[id] as PluginBaseConfig;
|
||||
const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig) as PluginBaseConfig;
|
||||
const oldConfig = oldPluginConfigList[id] as PluginConfig;
|
||||
const config = deepmerge(
|
||||
allPlugins[id].config,
|
||||
newPluginConfig,
|
||||
) as PluginConfig;
|
||||
|
||||
if (config.enabled !== oldConfig?.enabled) {
|
||||
if (config.enabled) {
|
||||
win.webContents.send('plugin:enable', id);
|
||||
ipcMain.emit('plugin:enable', id);
|
||||
forceLoadMainPlugin(id as keyof PluginBuilderList, win);
|
||||
forceLoadMainPlugin(id, win);
|
||||
} else {
|
||||
win.webContents.send('plugin:unload', id);
|
||||
ipcMain.emit('plugin:unload', id);
|
||||
forceUnloadMainPlugin(id as keyof PluginBuilderList, win);
|
||||
forceUnloadMainPlugin(id, win);
|
||||
}
|
||||
|
||||
if (pluginBuilders[id as keyof PluginBuilderList].restartNeeded) {
|
||||
showNeedToRestartDialog(id as keyof PluginBuilderList);
|
||||
if (mainPlugins[id]?.restartNeeded) {
|
||||
showNeedToRestartDialog(id);
|
||||
}
|
||||
}
|
||||
|
||||
const mainPlugin = getAllLoadedMainPlugins()[id];
|
||||
if (mainPlugin) {
|
||||
if (config.enabled) {
|
||||
mainPlugin.onConfigChange?.(config);
|
||||
if (config.enabled && typeof mainPlugin.backend !== 'function') {
|
||||
mainPlugin.backend?.onConfigChange?.bind(mainPlugin.backend)?.(config);
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,14 +173,15 @@ const initHook = (win: BrowserWindow) => {
|
||||
});
|
||||
};
|
||||
|
||||
const showNeedToRestartDialog = (id: keyof PluginBuilderList) => {
|
||||
const builder = pluginBuilders[id];
|
||||
const showNeedToRestartDialog = (id: string) => {
|
||||
const plugin = mainPlugins[id];
|
||||
|
||||
const dialogOptions: Electron.MessageBoxOptions = {
|
||||
type: 'info',
|
||||
buttons: ['Restart Now', 'Later'],
|
||||
title: 'Restart Required',
|
||||
message: `"${builder.name ?? builder.id}" needs to restart`,
|
||||
detail: `"${builder.name ?? builder.id}" plugin requires a restart to take effect`,
|
||||
message: `"${plugin.name ?? id}" needs to restart`,
|
||||
detail: `"${plugin.name ?? id}" plugin requires a restart to take effect`,
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
};
|
||||
@ -186,7 +220,10 @@ function initTheme(win: BrowserWindow) {
|
||||
injectCSSAsFile(win.webContents, cssFile);
|
||||
},
|
||||
() => {
|
||||
console.warn('[YTMusic]', `CSS file "${cssFile}" does not exist, ignoring`);
|
||||
console.warn(
|
||||
'[YTMusic]',
|
||||
`CSS file "${cssFile}" does not exist, ignoring`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -224,46 +261,43 @@ async function createMainWindow() {
|
||||
...(isTesting()
|
||||
? undefined
|
||||
: {
|
||||
// Sandbox is only enabled in tests for now
|
||||
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
||||
sandbox: false,
|
||||
}),
|
||||
// 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: defaultTitleBarOverlayOptions,
|
||||
titleBarStyle: useInlineMenu
|
||||
? 'hidden'
|
||||
: (is.macOS()
|
||||
? 'hiddenInset'
|
||||
: 'default'),
|
||||
: is.macOS()
|
||||
? 'hiddenInset'
|
||||
: 'default',
|
||||
autoHideMenuBar: config.get('options.hideMenu'),
|
||||
});
|
||||
initHook(win);
|
||||
initTheme(win);
|
||||
|
||||
Object.entries(pluginBuilders).forEach(([id, builder]) => {
|
||||
const typedBuilder = builder as PluginBuilder<string, PluginBaseConfig>;
|
||||
const plugin = mainPlugins[id] as MainPluginFactory<PluginBaseConfig> | undefined;
|
||||
|
||||
registerMainPlugin(id, typedBuilder, plugin);
|
||||
});
|
||||
await loadAllMainPlugins(win);
|
||||
|
||||
if (windowPosition) {
|
||||
const { x: windowX, y: windowY } = windowPosition;
|
||||
const winSize = win.getSize();
|
||||
const displaySize
|
||||
= screen.getDisplayNearestPoint(windowPosition).bounds;
|
||||
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
|
||||
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)}`,
|
||||
`Window tried to render offscreen, windowSize=${String(
|
||||
winSize,
|
||||
)}, displaySize=${String(displaySize)}, position=${String(
|
||||
windowPosition,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@ -316,7 +350,11 @@ async function createMainWindow() {
|
||||
|
||||
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
|
||||
|
||||
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) {
|
||||
function lateSave(
|
||||
key: string,
|
||||
value: unknown,
|
||||
fn: (key: string, value: unknown) => void = config.set,
|
||||
) {
|
||||
if (savedTimeouts[key]) {
|
||||
clearTimeout(savedTimeouts[key]);
|
||||
}
|
||||
@ -327,7 +365,7 @@ async function createMainWindow() {
|
||||
}, 600);
|
||||
}
|
||||
|
||||
app.on('render-process-gone', (event, webContents, details) => {
|
||||
app.on('render-process-gone', (_event, _webContents, details) => {
|
||||
showUnresponsiveDialog(win, details);
|
||||
});
|
||||
|
||||
@ -343,7 +381,10 @@ async function createMainWindow() {
|
||||
if (useInlineMenu) {
|
||||
win.setTitleBarOverlay({
|
||||
...defaultTitleBarOverlayOptions,
|
||||
height: Math.floor(defaultTitleBarOverlayOptions.height! * win.webContents.getZoomFactor()),
|
||||
height: Math.floor(
|
||||
defaultTitleBarOverlayOptions.height! *
|
||||
win.webContents.getZoomFactor(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@ -365,14 +406,25 @@ async function createMainWindow() {
|
||||
`);
|
||||
} else {
|
||||
const rendererPath = path.join(__dirname, '..', 'renderer');
|
||||
const indexHTML = parse(fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'));
|
||||
const indexHTML = parse(
|
||||
fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'),
|
||||
);
|
||||
const scriptSrc = indexHTML.querySelector('script')!;
|
||||
const scriptPath = path.join(rendererPath, scriptSrc.getAttribute('src')!);
|
||||
const scriptPath = path.join(
|
||||
rendererPath,
|
||||
scriptSrc.getAttribute('src')!,
|
||||
);
|
||||
const scriptString = fs.readFileSync(scriptPath, 'utf-8');
|
||||
await win.webContents.executeJavaScriptInIsolatedWorld(0, [{
|
||||
code: scriptString + ';0',
|
||||
url: url.pathToFileURL(scriptPath).toString(),
|
||||
}], true);
|
||||
await win.webContents.executeJavaScriptInIsolatedWorld(
|
||||
0,
|
||||
[
|
||||
{
|
||||
code: scriptString + ';0',
|
||||
url: url.pathToFileURL(scriptPath).toString(),
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -381,27 +433,32 @@ async function createMainWindow() {
|
||||
return win;
|
||||
}
|
||||
|
||||
app.once('browser-window-created', (event, 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',
|
||||
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);
|
||||
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')) {
|
||||
if (
|
||||
win.webContents.getURL().startsWith('https://accounts.google.com') &&
|
||||
details.url.startsWith('https://accounts.google.com')
|
||||
) {
|
||||
details.requestHeaders['User-Agent'] = originalUserAgent;
|
||||
}
|
||||
|
||||
@ -412,33 +469,41 @@ app.once('browser-window-created', (event, win) => {
|
||||
setupSongInfo(win);
|
||||
setupAppControls();
|
||||
|
||||
win.webContents.on('did-fail-load', (
|
||||
_event,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
isMainFrame,
|
||||
frameProcessId,
|
||||
frameRoutingId,
|
||||
) => {
|
||||
const log = JSON.stringify({
|
||||
error: 'did-fail-load',
|
||||
win.webContents.on(
|
||||
'did-fail-load',
|
||||
(
|
||||
_event,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
isMainFrame,
|
||||
frameProcessId,
|
||||
frameRoutingId,
|
||||
}, null, '\t');
|
||||
if (is.dev()) {
|
||||
console.log(log);
|
||||
}
|
||||
) => {
|
||||
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'));
|
||||
}
|
||||
});
|
||||
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();
|
||||
@ -484,17 +549,29 @@ app.on('ready', async () => {
|
||||
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
|
||||
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
|
||||
shortcutDetails.target !== appLocation ||
|
||||
shortcutDetails.appUserModelId !== appID
|
||||
) {
|
||||
throw 'needUpdate';
|
||||
}
|
||||
} catch (error) { // If not valid -> Register shortcut
|
||||
} catch (error) {
|
||||
// If not valid -> Register shortcut
|
||||
shell.writeShortcutLink(
|
||||
shortcutPath,
|
||||
error === 'needUpdate' ? 'update' : 'create',
|
||||
@ -556,8 +633,8 @@ app.on('ready', async () => {
|
||||
clearTimeout(updateTimeout);
|
||||
}, 2000);
|
||||
autoUpdater.on('update-available', () => {
|
||||
const downloadLink
|
||||
= 'https://github.com/th-ch/youtube-music/releases/latest';
|
||||
const downloadLink =
|
||||
'https://github.com/th-ch/youtube-music/releases/latest';
|
||||
const dialogOptions: Electron.MessageBoxOptions = {
|
||||
type: 'info',
|
||||
buttons: ['OK', 'Download', 'Disable updates'],
|
||||
@ -597,8 +674,10 @@ app.on('ready', async () => {
|
||||
|
||||
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)",
|
||||
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);
|
||||
}
|
||||
@ -624,31 +703,36 @@ app.on('ready', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
case 2: {
|
||||
app.quit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeContentSecurityPolicy(
|
||||
@ -671,18 +755,21 @@ function removeContentSecurityPolicy(
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
const result = await listener.apply();
|
||||
return { ...accumulator, ...result };
|
||||
},
|
||||
Promise.resolve({ cancel: false }),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,128 +1,138 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
import { mainPlugins } from 'virtual:plugins';
|
||||
|
||||
import config from '../config';
|
||||
import { injectCSS } from '../plugins/utils/main';
|
||||
import {
|
||||
MainPlugin,
|
||||
MainPluginContext,
|
||||
MainPluginFactory,
|
||||
PluginBaseConfig,
|
||||
PluginBuilder
|
||||
} from '../plugins/utils/builder';
|
||||
import config from '@/config';
|
||||
import { startPlugin, stopPlugin } from '@/utils';
|
||||
|
||||
const allPluginFactoryList: Record<string, MainPluginFactory<PluginBaseConfig>> = {};
|
||||
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
|
||||
const unregisterStyleMap: Record<string, (() => void)[]> = {};
|
||||
const loadedPluginMap: Record<string, MainPlugin<PluginBaseConfig>> = {};
|
||||
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
const createContext = <
|
||||
Key extends keyof PluginBuilderList,
|
||||
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
|
||||
>(id: Key, win: BrowserWindow): MainPluginContext<Config> => ({
|
||||
getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config,
|
||||
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
||||
|
||||
const createContext = (id: string, win: BrowserWindow): BackendContext<PluginConfig> => ({
|
||||
getConfig: () =>
|
||||
deepmerge(
|
||||
mainPlugins[id].config,
|
||||
config.get(`plugins.${id}`) ?? { enabled: false },
|
||||
) as PluginConfig,
|
||||
setConfig: (newConfig) => {
|
||||
config.setPartial(`plugins.${id}`, newConfig);
|
||||
},
|
||||
|
||||
send: (event: string, ...args: unknown[]) => {
|
||||
win.webContents.send(event, ...args);
|
||||
},
|
||||
handle: (event: string, listener) => {
|
||||
ipcMain.handle(event, async (_, ...args) => listener(...args as never));
|
||||
},
|
||||
on: (event: string, listener) => {
|
||||
ipcMain.on(event, async (_, ...args) => listener(...args as never));
|
||||
ipc: {
|
||||
send: (event: string, ...args: unknown[]) => {
|
||||
win.webContents.send(event, ...args);
|
||||
},
|
||||
handle: (event: string, listener: CallableFunction) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
ipcMain.handle(event, (_, ...args: unknown[]) => listener(...args));
|
||||
},
|
||||
on: (event: string, listener: CallableFunction) => {
|
||||
ipcMain.on(event, (_, ...args: unknown[]) => {
|
||||
listener(...args);
|
||||
});
|
||||
},
|
||||
removeHandler: (event: string) => {
|
||||
ipcMain.removeHandler(event);
|
||||
}
|
||||
},
|
||||
|
||||
window: win,
|
||||
});
|
||||
|
||||
export const forceUnloadMainPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => {
|
||||
unregisterStyleMap[id]?.forEach((unregister) => unregister());
|
||||
delete unregisterStyleMap[id];
|
||||
export const forceUnloadMainPlugin = async (
|
||||
id: string,
|
||||
win: BrowserWindow,
|
||||
): Promise<void> => {
|
||||
const plugin = loadedPluginMap[id];
|
||||
if (!plugin) return;
|
||||
|
||||
loadedPluginMap[id]?.onUnload?.(win);
|
||||
delete loadedPluginMap[id];
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
const hasStopped = stopPlugin(id, plugin, {
|
||||
ctx: 'backend',
|
||||
context: createContext(id, win),
|
||||
});
|
||||
if (!hasStopped) {
|
||||
console.log(
|
||||
'[YTMusic]',
|
||||
`Cannot unload "${id}" plugin: no stop function`,
|
||||
);
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[YTMusic]', `"${id}" plugin is unloaded`);
|
||||
delete loadedPluginMap[id];
|
||||
console.log('[YTMusic]', `"${id}" plugin is unloaded`);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
console.log('[YTMusic]', `Cannot unload "${id}" plugin: ${String(err)}`);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const forceLoadMainPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => {
|
||||
const builder = allPluginBuilders[id];
|
||||
export const forceLoadMainPlugin = async (
|
||||
id: string,
|
||||
win: BrowserWindow,
|
||||
): Promise<void> => {
|
||||
const plugin = mainPlugins[id];
|
||||
if (!plugin) return;
|
||||
|
||||
Promise.allSettled(
|
||||
builder.styles?.map(async (style) => {
|
||||
const unregister = await injectCSS(win.webContents, style);
|
||||
console.log('[YTMusic]', `Injected CSS for "${id}" plugin`);
|
||||
|
||||
return unregister;
|
||||
}) ?? [],
|
||||
).then((result) => {
|
||||
unregisterStyleMap[id] = result
|
||||
.map((it) => it.status === 'fulfilled' && it.value)
|
||||
.filter(Boolean);
|
||||
|
||||
let isInjectSuccess = true;
|
||||
result.forEach((it) => {
|
||||
if (it.status === 'rejected') {
|
||||
isInjectSuccess = false;
|
||||
|
||||
console.log('[YTMusic]', `Cannot inject "${id}" plugin style: ${String(it.reason)}`);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
const hasStarted = startPlugin(id, plugin, {
|
||||
ctx: 'backend',
|
||||
context: createContext(id, win),
|
||||
});
|
||||
if (!hasStarted) {
|
||||
console.log('[YTMusic]', `Cannot load "${id}" plugin`);
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (isInjectSuccess) console.log('[YTMusic]', `"${id}" plugin data is loaded`);
|
||||
|
||||
loadedPluginMap[id] = plugin;
|
||||
resolve();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[YTMusic]',
|
||||
`Cannot initialize "${id}" plugin: `,
|
||||
);
|
||||
console.trace(err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const factory = allPluginFactoryList[id];
|
||||
if (!factory) return;
|
||||
|
||||
const context = createContext(id, win);
|
||||
const plugin = await factory(context);
|
||||
loadedPluginMap[id] = plugin;
|
||||
plugin.onLoad?.(win);
|
||||
|
||||
console.log('[YTMusic]', `"${id}" plugin is loaded`);
|
||||
} catch (err) {
|
||||
console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadAllMainPlugins = async (win: BrowserWindow) => {
|
||||
console.log('[YTMusic]', 'Loading all plugins');
|
||||
const pluginConfigs = config.plugins.getPlugins();
|
||||
const queue: Promise<void>[] = [];
|
||||
|
||||
for (const [pluginId, builder] of Object.entries(allPluginBuilders)) {
|
||||
const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList];
|
||||
|
||||
const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {});
|
||||
|
||||
for (const [plugin, pluginDef] of Object.entries(mainPlugins)) {
|
||||
const config = deepmerge(pluginDef.config, pluginConfigs[plugin] ?? {});
|
||||
if (config.enabled) {
|
||||
await forceLoadMainPlugin(pluginId as keyof PluginBuilderList, win);
|
||||
} else {
|
||||
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) {
|
||||
forceUnloadMainPlugin(pluginId as keyof PluginBuilderList, win);
|
||||
}
|
||||
queue.push(forceLoadMainPlugin(plugin, win));
|
||||
} else if (loadedPluginMap[plugin]) {
|
||||
queue.push(forceUnloadMainPlugin(plugin, win));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(queue);
|
||||
};
|
||||
|
||||
export const unloadAllMainPlugins = (win: BrowserWindow) => {
|
||||
for (const id of Object.keys(loadedPluginMap)) {
|
||||
forceUnloadMainPlugin(id as keyof PluginBuilderList, win);
|
||||
forceUnloadMainPlugin(id, win);
|
||||
}
|
||||
};
|
||||
|
||||
export const getLoadedMainPlugin = <Key extends keyof PluginBuilderList>(id: Key): MainPlugin<PluginBuilderList[Key]['config']> | undefined => {
|
||||
export const getLoadedMainPlugin = (id: string): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||
return loadedPluginMap[id];
|
||||
};
|
||||
|
||||
export const getAllLoadedMainPlugins = () => {
|
||||
return loadedPluginMap;
|
||||
};
|
||||
export const registerMainPlugin = (
|
||||
id: string,
|
||||
builder: PluginBuilder<string, PluginBaseConfig>,
|
||||
factory?: MainPluginFactory<PluginBaseConfig>,
|
||||
) => {
|
||||
if (factory) allPluginFactoryList[id] = factory;
|
||||
allPluginBuilders[id] = builder;
|
||||
};
|
||||
|
||||
@ -1,26 +1,22 @@
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
import { allPlugins } from 'virtual:plugins';
|
||||
|
||||
import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig, PluginBuilder } from '../plugins/utils/builder';
|
||||
import config from '../config';
|
||||
import { setApplicationMenu } from '../menu';
|
||||
import config from '@/config';
|
||||
import { setApplicationMenu } from '@/menu';
|
||||
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
|
||||
import type { PluginConfig } from '@/types/plugins';
|
||||
|
||||
const allPluginFactoryList: Record<string, MenuPluginFactory<PluginBaseConfig>> = {};
|
||||
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
|
||||
const menuTemplateMap: Record<string, MenuItemConstructorOptions[]> = {};
|
||||
|
||||
const createContext = <
|
||||
Key extends keyof PluginBuilderList,
|
||||
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
|
||||
>(id: Key, win: BrowserWindow): MenuPluginContext<Config> => ({
|
||||
getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config,
|
||||
const createContext = (id: string, win: BrowserWindow): MenuContext<PluginConfig> => ({
|
||||
getConfig: () => config.plugins.getOptions(id),
|
||||
setConfig: (newConfig) => {
|
||||
config.setPartial(`plugins.${id}`, newConfig);
|
||||
},
|
||||
window: win,
|
||||
refresh: async () => {
|
||||
await setApplicationMenu(win);
|
||||
refresh: () => {
|
||||
setApplicationMenu(win);
|
||||
|
||||
if (config.plugins.isEnabled('in-app-menu')) {
|
||||
win.webContents.send('refresh-in-app-menu');
|
||||
@ -28,45 +24,40 @@ const createContext = <
|
||||
},
|
||||
});
|
||||
|
||||
export const forceLoadMenuPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => {
|
||||
export const forceLoadMenuPlugin = async (id: string, win: BrowserWindow) => {
|
||||
try {
|
||||
const factory = allPluginFactoryList[id];
|
||||
if (!factory) return;
|
||||
const plugin = allPlugins[id];
|
||||
if (!plugin) return;
|
||||
|
||||
const context = createContext(id, win);
|
||||
menuTemplateMap[id] = await factory(context);
|
||||
const menu = plugin.menu?.(createContext(id, win));
|
||||
if (menu) menuTemplateMap[id] = await menu;
|
||||
else return;
|
||||
|
||||
console.log('[YTMusic]', `"${id}" plugin is loaded`);
|
||||
console.log('[YTMusic]', `Successfully loaded '${id}::menu'`);
|
||||
} catch (err) {
|
||||
console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`);
|
||||
console.error('[YTMusic]', `Cannot initialize '${id}::menu': `);
|
||||
console.trace(err);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadAllMenuPlugins = async (win: BrowserWindow) => {
|
||||
export const loadAllMenuPlugins = (win: BrowserWindow) => {
|
||||
const pluginConfigs = config.plugins.getPlugins();
|
||||
|
||||
for (const [pluginId, builder] of Object.entries(allPluginBuilders)) {
|
||||
const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList];
|
||||
|
||||
const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {});
|
||||
for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
|
||||
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
|
||||
|
||||
if (config.enabled) {
|
||||
await forceLoadMenuPlugin(pluginId as keyof PluginBuilderList, win);
|
||||
forceLoadMenuPlugin(pluginId, win);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getMenuTemplate = <Key extends keyof PluginBuilderList>(id: Key): MenuItemConstructorOptions[] | undefined => {
|
||||
export const getMenuTemplate = (
|
||||
id: string,
|
||||
): MenuItemConstructorOptions[] | undefined => {
|
||||
return menuTemplateMap[id];
|
||||
};
|
||||
|
||||
export const getAllMenuTemplate = () => {
|
||||
return menuTemplateMap;
|
||||
};
|
||||
export const registerMenuPlugin = (
|
||||
id: string,
|
||||
builder: PluginBuilder<string, PluginBaseConfig>,
|
||||
factory?: MenuPluginFactory<PluginBaseConfig>,
|
||||
) => {
|
||||
if (factory) allPluginFactoryList[id] = factory;
|
||||
allPluginBuilders[id] = builder;
|
||||
};
|
||||
|
||||
@ -1,68 +1,66 @@
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
import { preloadPlugins } from 'virtual:plugins';
|
||||
|
||||
import {
|
||||
PluginBaseConfig,
|
||||
PluginBuilder,
|
||||
PreloadPlugin,
|
||||
PluginContext,
|
||||
PreloadPluginFactory
|
||||
} from '../plugins/utils/builder';
|
||||
import config from '../config';
|
||||
import { startPlugin, stopPlugin } from '@/utils';
|
||||
|
||||
const allPluginFactoryList: Record<string, PreloadPluginFactory<PluginBaseConfig>> = {};
|
||||
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
|
||||
const unregisterStyleMap: Record<string, (() => void)[]> = {};
|
||||
const loadedPluginMap: Record<string, PreloadPlugin<PluginBaseConfig>> = {};
|
||||
import config from '@/config';
|
||||
|
||||
const createContext = <
|
||||
Key extends keyof PluginBuilderList,
|
||||
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
|
||||
>(id: Key): PluginContext<Config> => ({
|
||||
getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config,
|
||||
import type { PreloadContext } from '@/types/contexts';
|
||||
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||
|
||||
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
||||
const createContext = (id: string): PreloadContext<PluginConfig> => ({
|
||||
getConfig: () => config.plugins.getOptions(id),
|
||||
setConfig: (newConfig) => {
|
||||
config.setPartial(`plugins.${id}`, newConfig);
|
||||
},
|
||||
});
|
||||
|
||||
export const forceUnloadPreloadPlugin = (id: keyof PluginBuilderList) => {
|
||||
unregisterStyleMap[id]?.forEach((unregister) => unregister());
|
||||
delete unregisterStyleMap[id];
|
||||
|
||||
loadedPluginMap[id]?.onUnload?.();
|
||||
delete loadedPluginMap[id];
|
||||
|
||||
export const forceUnloadPreloadPlugin = (id: string) => {
|
||||
const hasStopped = stopPlugin(id, loadedPluginMap[id], {
|
||||
ctx: 'preload',
|
||||
context: createContext(id),
|
||||
});
|
||||
if (!hasStopped) {
|
||||
console.log('[YTMusic]', `Cannot stop "${id}" plugin`);
|
||||
return;
|
||||
}
|
||||
console.log('[YTMusic]', `"${id}" plugin is unloaded`);
|
||||
};
|
||||
|
||||
export const forceLoadPreloadPlugin = async (id: keyof PluginBuilderList) => {
|
||||
export const forceLoadPreloadPlugin = (id: string) => {
|
||||
try {
|
||||
const factory = allPluginFactoryList[id];
|
||||
if (!factory) return;
|
||||
const plugin = preloadPlugins[id];
|
||||
if (!plugin) return;
|
||||
|
||||
const context = createContext(id);
|
||||
const plugin = await factory(context);
|
||||
loadedPluginMap[id] = plugin;
|
||||
plugin.onLoad?.();
|
||||
const hasStarted = startPlugin(id, plugin, {
|
||||
ctx: 'preload',
|
||||
context: createContext(id),
|
||||
});
|
||||
|
||||
if (hasStarted) loadedPluginMap[id] = plugin;
|
||||
|
||||
console.log('[YTMusic]', `"${id}" plugin is loaded`);
|
||||
} catch (err) {
|
||||
console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`);
|
||||
console.error(
|
||||
'[YTMusic]',
|
||||
`Cannot initialize "${id}" plugin: `,
|
||||
);
|
||||
console.trace(err);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadAllPreloadPlugins = async () => {
|
||||
export const loadAllPreloadPlugins = () => {
|
||||
const pluginConfigs = config.plugins.getPlugins();
|
||||
|
||||
for (const [pluginId, builder] of Object.entries(allPluginBuilders)) {
|
||||
const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList];
|
||||
|
||||
const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {});
|
||||
for (const [pluginId, pluginDef] of Object.entries(preloadPlugins)) {
|
||||
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}) ;
|
||||
|
||||
if (config.enabled) {
|
||||
await forceLoadPreloadPlugin(pluginId as keyof PluginBuilderList);
|
||||
forceLoadPreloadPlugin(pluginId);
|
||||
} else {
|
||||
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) {
|
||||
forceUnloadPreloadPlugin(pluginId as keyof PluginBuilderList);
|
||||
if (loadedPluginMap[pluginId]) {
|
||||
forceUnloadPreloadPlugin(pluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -70,21 +68,14 @@ export const loadAllPreloadPlugins = async () => {
|
||||
|
||||
export const unloadAllPreloadPlugins = () => {
|
||||
for (const id of Object.keys(loadedPluginMap)) {
|
||||
forceUnloadPreloadPlugin(id as keyof PluginBuilderList);
|
||||
forceUnloadPreloadPlugin(id);
|
||||
}
|
||||
};
|
||||
|
||||
export const getLoadedPreloadPlugin = <Key extends keyof PluginBuilderList>(id: Key): PreloadPlugin<PluginBuilderList[Key]['config']> | undefined => {
|
||||
export const getLoadedPreloadPlugin = (id: string): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||
return loadedPluginMap[id];
|
||||
};
|
||||
|
||||
export const getAllLoadedPreloadPlugins = () => {
|
||||
return loadedPluginMap;
|
||||
};
|
||||
export const registerPreloadPlugin = (
|
||||
id: string,
|
||||
builder: PluginBuilder<string, PluginBaseConfig>,
|
||||
factory?: PreloadPluginFactory<PluginBaseConfig>,
|
||||
) => {
|
||||
if (factory) allPluginFactoryList[id] = factory;
|
||||
allPluginBuilders[id] = builder;
|
||||
};
|
||||
|
||||
@ -1,75 +1,92 @@
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
|
||||
import {
|
||||
PluginBaseConfig, PluginBuilder,
|
||||
RendererPlugin,
|
||||
RendererPluginContext,
|
||||
RendererPluginFactory
|
||||
} from '../plugins/utils/builder';
|
||||
import { rendererPlugins } from 'virtual:plugins';
|
||||
|
||||
import { startPlugin, stopPlugin } from '@/utils';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||
|
||||
const allPluginFactoryList: Record<string, RendererPluginFactory<PluginBaseConfig>> = {};
|
||||
const allPluginBuilders: Record<string, PluginBuilder<string, PluginBaseConfig>> = {};
|
||||
const unregisterStyleMap: Record<string, (() => void)[]> = {};
|
||||
const loadedPluginMap: Record<string, RendererPlugin<PluginBaseConfig>> = {};
|
||||
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
||||
|
||||
const createContext = <
|
||||
Key extends keyof PluginBuilderList,
|
||||
Config extends PluginBaseConfig = PluginBuilderList[Key]['config'],
|
||||
>(id: Key): RendererPluginContext<Config> => ({
|
||||
getConfig: async () => {
|
||||
return await window.ipcRenderer.invoke('get-config', id) as Config;
|
||||
},
|
||||
export const createContext = <Config extends PluginConfig>(id: string): RendererContext<Config> => ({
|
||||
getConfig: async () => window.ipcRenderer.invoke('get-config', id),
|
||||
setConfig: async (newConfig) => {
|
||||
await window.ipcRenderer.invoke('set-config', id, newConfig);
|
||||
},
|
||||
|
||||
invoke: async <Return>(event: string, ...args: unknown[]): Promise<Return> => {
|
||||
return await window.ipcRenderer.invoke(event, ...args) as Return;
|
||||
},
|
||||
on: (event: string, listener) => {
|
||||
window.ipcRenderer.on(event, async (_, ...args) => listener(...args as never));
|
||||
ipc: {
|
||||
send: (event: string, ...args: unknown[]) => {
|
||||
window.ipcRenderer.send(event, ...args);
|
||||
},
|
||||
invoke: (event: string, ...args: unknown[]) => window.ipcRenderer.invoke(event, ...args),
|
||||
on: (event: string, listener: CallableFunction) => {
|
||||
window.ipcRenderer.on(event, (_, ...args: unknown[]) => {
|
||||
listener(...args);
|
||||
});
|
||||
},
|
||||
removeAllListeners: (event: string) => {
|
||||
window.ipcRenderer.removeAllListeners(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const forceUnloadRendererPlugin = (id: keyof PluginBuilderList) => {
|
||||
export const forceUnloadRendererPlugin = (id: string) => {
|
||||
unregisterStyleMap[id]?.forEach((unregister) => unregister());
|
||||
delete unregisterStyleMap[id];
|
||||
|
||||
loadedPluginMap[id]?.onUnload?.();
|
||||
delete unregisterStyleMap[id];
|
||||
delete loadedPluginMap[id];
|
||||
|
||||
const plugin = rendererPlugins[id];
|
||||
if (!plugin) return;
|
||||
|
||||
stopPlugin(id, plugin, { ctx: 'renderer', context: createContext(id) });
|
||||
if (plugin?.stylesheets)
|
||||
document.querySelector(`style#plugin-${id}`)?.remove();
|
||||
|
||||
console.log('[YTMusic]', `"${id}" plugin is unloaded`);
|
||||
};
|
||||
|
||||
export const forceLoadRendererPlugin = async (id: keyof PluginBuilderList) => {
|
||||
try {
|
||||
const factory = allPluginFactoryList[id];
|
||||
if (!factory) return;
|
||||
export const forceLoadRendererPlugin = (id: string) => {
|
||||
const plugin = rendererPlugins[id];
|
||||
if (!plugin) return;
|
||||
|
||||
const context = createContext(id);
|
||||
const plugin = await factory(context);
|
||||
const hasEvaled = startPlugin(id, plugin, {
|
||||
ctx: 'renderer',
|
||||
context: createContext(id),
|
||||
});
|
||||
|
||||
if (hasEvaled || plugin?.stylesheets) {
|
||||
loadedPluginMap[id] = plugin;
|
||||
plugin.onLoad?.();
|
||||
|
||||
console.log('[YTMusic]', `"${id}" plugin is loaded`);
|
||||
} catch (err) {
|
||||
console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`);
|
||||
if (plugin?.stylesheets) {
|
||||
const styleSheetList = plugin.stylesheets.map((style) => {
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
styleSheet.replaceSync(style);
|
||||
|
||||
return styleSheet;
|
||||
});
|
||||
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, ...styleSheetList];
|
||||
}
|
||||
|
||||
if (!hasEvaled) console.log('[YTMusic]', `"${id}" plugin is loaded`);
|
||||
} else {
|
||||
console.log('[YTMusic]', `Cannot initialize "${id}" plugin`);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadAllRendererPlugins = async () => {
|
||||
export const loadAllRendererPlugins = () => {
|
||||
const pluginConfigs = window.mainConfig.plugins.getPlugins();
|
||||
|
||||
for (const [pluginId, builder] of Object.entries(allPluginBuilders)) {
|
||||
const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList];
|
||||
|
||||
const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {});
|
||||
for (const [pluginId, pluginDef] of Object.entries(rendererPlugins)) {
|
||||
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}) ;
|
||||
|
||||
if (config.enabled) {
|
||||
await forceLoadRendererPlugin(pluginId as keyof PluginBuilderList);
|
||||
forceLoadRendererPlugin(pluginId);
|
||||
} else {
|
||||
if (loadedPluginMap[pluginId as keyof PluginBuilderList]) {
|
||||
forceUnloadRendererPlugin(pluginId as keyof PluginBuilderList);
|
||||
if (loadedPluginMap[pluginId]) {
|
||||
forceUnloadRendererPlugin(pluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,21 +94,14 @@ export const loadAllRendererPlugins = async () => {
|
||||
|
||||
export const unloadAllRendererPlugins = () => {
|
||||
for (const id of Object.keys(loadedPluginMap)) {
|
||||
forceUnloadRendererPlugin(id as keyof PluginBuilderList);
|
||||
forceUnloadRendererPlugin(id);
|
||||
}
|
||||
};
|
||||
|
||||
export const getLoadedRendererPlugin = <Key extends keyof PluginBuilderList>(id: Key): RendererPlugin<PluginBuilderList[Key]['config']> | undefined => {
|
||||
export const getLoadedRendererPlugin = (id: string): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||
return loadedPluginMap[id];
|
||||
};
|
||||
|
||||
export const getAllLoadedRendererPlugins = () => {
|
||||
return loadedPluginMap;
|
||||
};
|
||||
export const registerRendererPlugin = (
|
||||
id: string,
|
||||
builder: PluginBuilder<string, PluginBaseConfig>,
|
||||
factory?: RendererPluginFactory<PluginBaseConfig>,
|
||||
) => {
|
||||
if (factory) allPluginFactoryList[id] = factory;
|
||||
allPluginBuilders[id] = builder;
|
||||
};
|
||||
|
||||
295
src/menu.ts
295
src/menu.ts
@ -1,31 +1,35 @@
|
||||
import is from 'electron-is';
|
||||
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
dialog,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from 'electron';
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { restart } from './providers/app-controls';
|
||||
import { allPlugins } from 'virtual:plugins';
|
||||
|
||||
import config from './config';
|
||||
|
||||
import { restart } from './providers/app-controls';
|
||||
import { startingPages } from './providers/extracted-data';
|
||||
import promptOptions from './providers/prompt-options';
|
||||
|
||||
/* eslint-disable import/order */
|
||||
import { menuPlugins as menuList } from 'virtual:MenuPlugins';
|
||||
import { pluginBuilders } from 'virtual:PluginBuilders';
|
||||
/* eslint-enable import/order */
|
||||
|
||||
import { getAvailablePluginNames } from './plugins/utils/main';
|
||||
import {
|
||||
MenuPluginFactory,
|
||||
PluginBaseConfig,
|
||||
PluginBuilder
|
||||
} from './plugins/utils/builder';
|
||||
import { getAllMenuTemplate, loadAllMenuPlugins, registerMenuPlugin } from './loader/menu';
|
||||
import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
|
||||
|
||||
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
|
||||
|
||||
// True only if in-app-menu was loaded on launch
|
||||
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
|
||||
|
||||
const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
|
||||
const pluginEnabledMenu = (
|
||||
plugin: string,
|
||||
label = '',
|
||||
hasSubmenu = false,
|
||||
refreshMenu: (() => void) | undefined = undefined,
|
||||
): Electron.MenuItemConstructorOptions => ({
|
||||
label: label || plugin,
|
||||
type: 'checkbox',
|
||||
checked: config.plugins.isEnabled(plugin),
|
||||
@ -49,50 +53,52 @@ export const refreshMenu = (win: BrowserWindow) => {
|
||||
}
|
||||
};
|
||||
|
||||
Object.entries(pluginBuilders).forEach(([id, builder]) => {
|
||||
const typedBuilder = builder as PluginBuilder<string, PluginBaseConfig>;
|
||||
const plugin = menuList[id] as MenuPluginFactory<PluginBaseConfig> | undefined;
|
||||
|
||||
registerMenuPlugin(id, typedBuilder, plugin);
|
||||
});
|
||||
|
||||
export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate> => {
|
||||
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
||||
const innerRefreshMenu = () => refreshMenu(win);
|
||||
|
||||
await loadAllMenuPlugins(win);
|
||||
loadAllMenuPlugins(win);
|
||||
|
||||
const menuResult = Object.entries(getAllMenuTemplate()).map(([id, template]) => {
|
||||
const pluginLabel = (pluginBuilders[id as keyof PluginBuilderList])?.name ?? id;
|
||||
const menuResult = Object.entries(getAllMenuTemplate()).map(
|
||||
([id, template]) => {
|
||||
const pluginLabel = allPlugins[id]?.name ?? id;
|
||||
|
||||
if (!config.plugins.isEnabled(id)) {
|
||||
return [
|
||||
id,
|
||||
pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu),
|
||||
] as const;
|
||||
}
|
||||
|
||||
if (!config.plugins.isEnabled(id)) {
|
||||
return [
|
||||
id,
|
||||
pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu),
|
||||
{
|
||||
label: pluginLabel,
|
||||
submenu: [
|
||||
pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu),
|
||||
{ type: 'separator' },
|
||||
...template,
|
||||
],
|
||||
} satisfies Electron.MenuItemConstructorOptions,
|
||||
] as const;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
id,
|
||||
{
|
||||
label: pluginLabel,
|
||||
submenu: [
|
||||
pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu),
|
||||
{ type: 'separator' },
|
||||
...template,
|
||||
],
|
||||
} satisfies Electron.MenuItemConstructorOptions
|
||||
] as const;
|
||||
});
|
||||
const availablePlugins = Object.keys(allPlugins);
|
||||
const pluginMenus = availablePlugins
|
||||
.sort((a, b) => {
|
||||
const aPluginLabel = allPlugins[a]?.name ?? a;
|
||||
const bPluginLabel = allPlugins[b]?.name ?? b;
|
||||
|
||||
const availablePlugins = getAvailablePluginNames();
|
||||
const pluginMenus = availablePlugins.map((id) => {
|
||||
const predefinedTemplate = menuResult.find((it) => it[0] === id);
|
||||
if (predefinedTemplate) return predefinedTemplate[1];
|
||||
return aPluginLabel.localeCompare(bPluginLabel);
|
||||
})
|
||||
.map((id) => {
|
||||
const predefinedTemplate = menuResult.find((it) => it[0] === id);
|
||||
if (predefinedTemplate) return predefinedTemplate[1];
|
||||
|
||||
const pluginLabel = pluginBuilders[id as keyof PluginBuilderList]?.name ?? id;
|
||||
const pluginLabel = allPlugins[id]?.name ?? id;
|
||||
|
||||
return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu);
|
||||
});
|
||||
return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu);
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
@ -106,7 +112,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
label: 'Auto-update',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.autoUpdates'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption('options.autoUpdates', item.checked);
|
||||
},
|
||||
},
|
||||
@ -114,21 +120,22 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
label: 'Resume last song when app starts',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.resumeOnStart'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
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);
|
||||
},
|
||||
}));
|
||||
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',
|
||||
@ -147,8 +154,11 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
label: 'Remove upgrade button',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.removeUpgradeButton'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.removeUpgradeButton', item.checked);
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption(
|
||||
'options.removeUpgradeButton',
|
||||
item.checked,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -213,7 +223,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
label: 'Single instance lock',
|
||||
type: 'checkbox',
|
||||
checked: true,
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
if (!item.checked && app.hasSingleInstanceLock()) {
|
||||
app.releaseSingleInstanceLock();
|
||||
} else if (item.checked && !app.hasSingleInstanceLock()) {
|
||||
@ -225,43 +235,45 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
label: 'Always on top',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.alwaysOnTop'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption('options.alwaysOnTop', item.checked);
|
||||
win.setAlwaysOnTop(item.checked);
|
||||
},
|
||||
},
|
||||
...(is.windows() || is.linux()
|
||||
...((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)',
|
||||
});
|
||||
}
|
||||
{
|
||||
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()
|
||||
]
|
||||
: []) 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);
|
||||
// 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[],
|
||||
]
|
||||
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||
{
|
||||
label: 'Tray',
|
||||
submenu: [
|
||||
@ -277,7 +289,8 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
{
|
||||
label: 'Enabled + app visible',
|
||||
type: 'radio',
|
||||
checked: config.get('options.tray') && config.get('options.appVisible'),
|
||||
checked:
|
||||
config.get('options.tray') && config.get('options.appVisible'),
|
||||
click() {
|
||||
config.setMenuOption('options.tray', true);
|
||||
config.setMenuOption('options.appVisible', true);
|
||||
@ -286,7 +299,8 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
{
|
||||
label: 'Enabled + app hidden',
|
||||
type: 'radio',
|
||||
checked: config.get('options.tray') && !config.get('options.appVisible'),
|
||||
checked:
|
||||
config.get('options.tray') && !config.get('options.appVisible'),
|
||||
click() {
|
||||
config.setMenuOption('options.tray', true);
|
||||
config.setMenuOption('options.appVisible', false);
|
||||
@ -297,8 +311,11 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
label: 'Play/Pause on click',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.trayClickPlayPause'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.trayClickPlayPause', item.checked);
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption(
|
||||
'options.trayClickPlayPause',
|
||||
item.checked,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -310,7 +327,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
{
|
||||
label: 'Set Proxy',
|
||||
type: 'normal',
|
||||
async click(item) {
|
||||
async click(item: MenuItem) {
|
||||
await setProxy(item, win);
|
||||
},
|
||||
},
|
||||
@ -318,7 +335,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
label: 'Override useragent',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.overrideUserAgent'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption('options.overrideUserAgent', item.checked);
|
||||
},
|
||||
},
|
||||
@ -326,40 +343,46 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
label: 'Disable hardware acceleration',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.disableHardwareAcceleration'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.disableHardwareAcceleration', item.checked);
|
||||
click(item: MenuItem) {
|
||||
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);
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption(
|
||||
'options.restartOnConfigChanges',
|
||||
item.checked,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Reset App cache when app starts',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.autoResetAppCache'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
}
|
||||
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',
|
||||
@ -377,8 +400,14 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
{ 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: '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' },
|
||||
@ -419,14 +448,12 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
},
|
||||
{
|
||||
label: 'About',
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
],
|
||||
}
|
||||
submenu: [{ role: 'about' }],
|
||||
},
|
||||
];
|
||||
};
|
||||
export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
|
||||
const menuTemplate: MenuTemplate = [...await mainMenuTemplate(win)];
|
||||
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
|
||||
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
|
||||
if (process.platform === 'darwin') {
|
||||
const { name } = app;
|
||||
menuTemplate.unshift({
|
||||
@ -455,23 +482,27 @@ export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
|
||||
};
|
||||
|
||||
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",
|
||||
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(),
|
||||
},
|
||||
width: 450,
|
||||
...promptOptions(),
|
||||
}, win);
|
||||
win,
|
||||
);
|
||||
|
||||
if (typeof output === 'string') {
|
||||
config.setMenuOption('options.proxy', output);
|
||||
item.checked = output !== '';
|
||||
} else { // User pressed cancel
|
||||
} else {
|
||||
// User pressed cancel
|
||||
item.checked = !item.checked; // Reset checkbox
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { blockers } from './types';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from './blocker';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import injectCliqzPreload from './injectors/inject-cliqz-preload';
|
||||
import { inject, isInjected } from './injectors/inject';
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
interface AdblockerConfig {
|
||||
/**
|
||||
@ -31,7 +36,7 @@ interface AdblockerConfig {
|
||||
disableDefaultLists: boolean;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('adblocker', {
|
||||
export default createPlugin({
|
||||
name: 'Adblocker',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
@ -41,12 +46,76 @@ const builder = createPluginBuilder('adblocker', {
|
||||
additionalBlockLists: [],
|
||||
disableDefaultLists: false,
|
||||
} as AdblockerConfig,
|
||||
});
|
||||
menu: async ({ getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
export default builder;
|
||||
return [
|
||||
{
|
||||
label: 'Blocker',
|
||||
submenu: Object.values(blockers).map((blocker) => ({
|
||||
label: blocker,
|
||||
type: 'radio',
|
||||
checked: (config.blocker || blockers.WithBlocklists) === blocker,
|
||||
click() {
|
||||
setConfig({ blocker });
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
},
|
||||
backend: {
|
||||
mainWindow: null as BrowserWindow | null,
|
||||
async start({ getConfig, window }) {
|
||||
const config = await getConfig();
|
||||
this.mainWindow = window;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
if (config.blocker === blockers.WithBlocklists) {
|
||||
await loadAdBlockerEngine(
|
||||
window.webContents.session,
|
||||
config.cache,
|
||||
config.additionalBlockLists,
|
||||
config.disableDefaultLists,
|
||||
);
|
||||
}
|
||||
},
|
||||
stop({ window }) {
|
||||
if (isBlockerEnabled(window.webContents.session)) {
|
||||
unloadAdBlockerEngine(window.webContents.session);
|
||||
}
|
||||
},
|
||||
async onConfigChange(newConfig) {
|
||||
console.log('Adblocker config changed', newConfig);
|
||||
if (this.mainWindow) {
|
||||
if (newConfig.blocker === blockers.WithBlocklists && !isBlockerEnabled(this.mainWindow.webContents.session)) {
|
||||
await loadAdBlockerEngine(
|
||||
this.mainWindow.webContents.session,
|
||||
newConfig.cache,
|
||||
newConfig.additionalBlockLists,
|
||||
newConfig.disableDefaultLists,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
async start({ getConfig }) {
|
||||
const config = await getConfig();
|
||||
|
||||
if (config.blocker === blockers.WithBlocklists) {
|
||||
// Preload adblocker to inject scripts/styles
|
||||
await injectCliqzPreload();
|
||||
} else if (config.blocker === blockers.InPlayer) {
|
||||
inject();
|
||||
}
|
||||
},
|
||||
async onConfigChange(newConfig) {
|
||||
if (newConfig.blocker === blockers.WithBlocklists) {
|
||||
await injectCliqzPreload();
|
||||
} else if (newConfig.blocker === blockers.InPlayer) {
|
||||
if (!isInjected()) {
|
||||
inject();
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from './blocker';
|
||||
|
||||
import builder from './index';
|
||||
import { blockers } from './types';
|
||||
|
||||
export default builder.createMain(({ getConfig }) => {
|
||||
let mainWindow: BrowserWindow | undefined;
|
||||
|
||||
return {
|
||||
async onLoad(window) {
|
||||
const config = await getConfig();
|
||||
mainWindow = window;
|
||||
|
||||
if (config.blocker === blockers.WithBlocklists) {
|
||||
await loadAdBlockerEngine(
|
||||
window.webContents.session,
|
||||
config.cache,
|
||||
config.additionalBlockLists,
|
||||
config.disableDefaultLists,
|
||||
);
|
||||
}
|
||||
},
|
||||
onUnload(window) {
|
||||
if (isBlockerEnabled(window.webContents.session)) {
|
||||
unloadAdBlockerEngine(window.webContents.session);
|
||||
}
|
||||
},
|
||||
async onConfigChange(newConfig) {
|
||||
if (mainWindow) {
|
||||
if (newConfig.blocker === blockers.WithBlocklists && !isBlockerEnabled(mainWindow.webContents.session)) {
|
||||
await loadAdBlockerEngine(
|
||||
mainWindow.webContents.session,
|
||||
newConfig.cache,
|
||||
newConfig.additionalBlockLists,
|
||||
newConfig.disableDefaultLists,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,22 +0,0 @@
|
||||
import { blockers } from './types';
|
||||
import builder from './index';
|
||||
|
||||
import type { MenuTemplate } from '../../menu';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Blocker',
|
||||
submenu: Object.values(blockers).map((blocker) => ({
|
||||
label: blocker,
|
||||
type: 'radio',
|
||||
checked: (config.blocker || blockers.WithBlocklists) === blocker,
|
||||
click() {
|
||||
setConfig({ blocker });
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,27 +0,0 @@
|
||||
import { inject, isInjected } from './injectors/inject';
|
||||
import injectCliqzPreload from './injectors/inject-cliqz-preload';
|
||||
|
||||
import { blockers } from './types';
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createPreload(({ getConfig }) => ({
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
|
||||
if (config.blocker === blockers.WithBlocklists) {
|
||||
// Preload adblocker to inject scripts/styles
|
||||
await injectCliqzPreload();
|
||||
} else if (config.blocker === blockers.InPlayer) {
|
||||
inject();
|
||||
}
|
||||
},
|
||||
async onConfigChange(newConfig) {
|
||||
if (newConfig.blocker === blockers.WithBlocklists) {
|
||||
await injectCliqzPreload();
|
||||
} else if (newConfig.blocker === blockers.InPlayer) {
|
||||
if (!isInjected()) {
|
||||
inject();
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
@ -1,20 +1,144 @@
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
import style from './style.css?inline';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
const builder = createPluginBuilder('album-color-theme', {
|
||||
export default createPlugin({
|
||||
name: 'Album Color Theme',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
styles: [style],
|
||||
});
|
||||
stylesheets: [style],
|
||||
renderer: {
|
||||
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;
|
||||
|
||||
export default builder;
|
||||
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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
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];
|
||||
},
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
lightness: 0,
|
||||
|
||||
changeElementColor: (element: HTMLElement | null, hue: number, saturation: number, lightness: number) => {
|
||||
if (element) {
|
||||
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
}
|
||||
},
|
||||
|
||||
playerPage: null as HTMLElement | null,
|
||||
navBarBackground: null as HTMLElement | null,
|
||||
ytmusicPlayerBar: null as HTMLElement | null,
|
||||
playerBarBackground: null as HTMLElement | null,
|
||||
sidebarBig: null as HTMLElement | null,
|
||||
sidebarSmall: null as HTMLElement | null,
|
||||
ytmusicAppLayout: null as HTMLElement | null,
|
||||
|
||||
start() {
|
||||
this.playerPage = document.querySelector<HTMLElement>('#player-page');
|
||||
this.navBarBackground = document.querySelector<HTMLElement>('#nav-bar-background');
|
||||
this.ytmusicPlayerBar = document.querySelector<HTMLElement>('ytmusic-player-bar');
|
||||
this.playerBarBackground = document.querySelector<HTMLElement>('#player-bar-background');
|
||||
this.sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
|
||||
this.sidebarSmall = document.querySelector<HTMLElement>('#mini-guide-background');
|
||||
this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
||||
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'attributes') {
|
||||
const isPageOpen = this.ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||
if (isPageOpen) {
|
||||
this.changeElementColor(this.sidebarSmall, this.hue, this.saturation, this.lightness - 30);
|
||||
} else {
|
||||
if (this.sidebarSmall) {
|
||||
this.sidebarSmall.style.backgroundColor = 'black';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (this.playerPage) {
|
||||
observer.observe(this.playerPage, { attributes: true });
|
||||
}
|
||||
},
|
||||
onPlayerApiReady(playerApi) {
|
||||
const fastAverageColor = new FastAverageColor();
|
||||
|
||||
playerApi.addEventListener('videodatachange', (name: string) => {
|
||||
if (name === 'dataloaded') {
|
||||
const playerResponse = playerApi.getPlayerResponse();
|
||||
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
|
||||
if (thumbnail) {
|
||||
fastAverageColor.getColorAsync(thumbnail.url)
|
||||
.then((albumColor) => {
|
||||
if (albumColor) {
|
||||
const [hue, saturation, lightness] = this.hexToHSL(albumColor.hex);
|
||||
this.changeElementColor(this.playerPage, hue, saturation, lightness - 30);
|
||||
this.changeElementColor(this.navBarBackground, hue, saturation, lightness - 15);
|
||||
this.changeElementColor(this.ytmusicPlayerBar, hue, saturation, lightness - 15);
|
||||
this.changeElementColor(this.playerBarBackground, hue, saturation, lightness - 15);
|
||||
this.changeElementColor(this.sidebarBig, hue, saturation, lightness - 15);
|
||||
if (this.ytmusicAppLayout?.hasAttribute('player-page-open')) {
|
||||
this.changeElementColor(this.sidebarSmall, hue, saturation, lightness - 30);
|
||||
}
|
||||
const ytRightClickList = document.querySelector<HTMLElement>('tp-yt-paper-listbox');
|
||||
this.changeElementColor(ytRightClickList, hue, saturation, lightness - 15);
|
||||
} else {
|
||||
if (this.playerPage) {
|
||||
this.playerPage.style.backgroundColor = '#000000';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,137 +0,0 @@
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => {
|
||||
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}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
let playerPage: HTMLElement | null = null;
|
||||
let navBarBackground: HTMLElement | null = null;
|
||||
let ytmusicPlayerBar: HTMLElement | null = null;
|
||||
let playerBarBackground: HTMLElement | null = null;
|
||||
let sidebarBig: HTMLElement | null = null;
|
||||
let sidebarSmall: HTMLElement | null = null;
|
||||
let ytmusicAppLayout: HTMLElement | null = null;
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
playerPage = document.querySelector<HTMLElement>('#player-page');
|
||||
navBarBackground = document.querySelector<HTMLElement>('#nav-bar-background');
|
||||
ytmusicPlayerBar = document.querySelector<HTMLElement>('ytmusic-player-bar');
|
||||
playerBarBackground = document.querySelector<HTMLElement>('#player-bar-background');
|
||||
sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
|
||||
sidebarSmall = document.querySelector<HTMLElement>('#mini-guide-background');
|
||||
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 });
|
||||
}
|
||||
},
|
||||
onPlayerApiReady(playerApi) {
|
||||
const fastAverageColor = new FastAverageColor();
|
||||
|
||||
playerApi.addEventListener('videodatachange', (name: string) => {
|
||||
if (name === 'dataloaded') {
|
||||
const playerResponse = playerApi.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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import style from './style.css?inline';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
export type AmbientModePluginConfig = {
|
||||
enabled: boolean;
|
||||
@ -12,26 +12,286 @@ export type AmbientModePluginConfig = {
|
||||
opacity: number;
|
||||
fullscreen: boolean;
|
||||
};
|
||||
const builder = createPluginBuilder('ambient-mode', {
|
||||
const defaultConfig: AmbientModePluginConfig = {
|
||||
enabled: false,
|
||||
quality: 50,
|
||||
buffer: 30,
|
||||
interpolationTime: 1500,
|
||||
blur: 100,
|
||||
size: 100,
|
||||
opacity: 1,
|
||||
fullscreen: false,
|
||||
};
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Ambient Mode',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
quality: 50,
|
||||
buffer: 30,
|
||||
interpolationTime: 1500,
|
||||
blur: 100,
|
||||
size: 100,
|
||||
opacity: 1,
|
||||
fullscreen: false,
|
||||
} as AmbientModePluginConfig,
|
||||
styles: [style],
|
||||
});
|
||||
config: defaultConfig,
|
||||
stylesheets: [style],
|
||||
menu: async ({ getConfig, setConfig }) => {
|
||||
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
|
||||
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
|
||||
const sizeList = [100, 110, 125, 150, 175, 200, 300];
|
||||
const bufferList = [1, 5, 10, 20, 30];
|
||||
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
|
||||
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
|
||||
|
||||
export default builder;
|
||||
const config = await getConfig();
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
return [
|
||||
{
|
||||
label: 'Smoothness transition',
|
||||
submenu: interpolationTimeList.map((interpolationTime) => ({
|
||||
label: `During ${interpolationTime / 1000}s`,
|
||||
type: 'radio',
|
||||
checked: config.interpolationTime === interpolationTime,
|
||||
click() {
|
||||
setConfig({ interpolationTime });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Quality',
|
||||
submenu: qualityList.map((quality) => ({
|
||||
label: `${quality} pixels`,
|
||||
type: 'radio',
|
||||
checked: config.quality === quality,
|
||||
click() {
|
||||
setConfig({ quality });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Size',
|
||||
submenu: sizeList.map((size) => ({
|
||||
label: `${size}%`,
|
||||
type: 'radio',
|
||||
checked: config.size === size,
|
||||
click() {
|
||||
setConfig({ size });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Buffer',
|
||||
submenu: bufferList.map((buffer) => ({
|
||||
label: `${buffer}`,
|
||||
type: 'radio',
|
||||
checked: config.buffer === buffer,
|
||||
click() {
|
||||
setConfig({ buffer });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Opacity',
|
||||
submenu: opacityList.map((opacity) => ({
|
||||
label: `${opacity * 100}%`,
|
||||
type: 'radio',
|
||||
checked: config.opacity === opacity,
|
||||
click() {
|
||||
setConfig({ opacity });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Blur amount',
|
||||
submenu: blurAmountList.map((blur) => ({
|
||||
label: `${blur} pixels`,
|
||||
type: 'radio',
|
||||
checked: config.blur === blur,
|
||||
click() {
|
||||
setConfig({ blur });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Using fullscreen',
|
||||
type: 'checkbox',
|
||||
checked: config.fullscreen,
|
||||
click(item) {
|
||||
setConfig({ fullscreen: item.checked });
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderer: {
|
||||
interpolationTime: defaultConfig.interpolationTime,
|
||||
buffer: defaultConfig.buffer,
|
||||
qualityRatio: defaultConfig.quality,
|
||||
sizeRatio: defaultConfig.size / 100,
|
||||
blur: defaultConfig.blur,
|
||||
opacity: defaultConfig.opacity,
|
||||
isFullscreen: defaultConfig.fullscreen,
|
||||
|
||||
unregister: null as (() => void) | null,
|
||||
update: null as (() => void) | null,
|
||||
observer: null as MutationObserver | null,
|
||||
|
||||
start() {
|
||||
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(() => {
|
||||
// console.log('context', context);
|
||||
if (!context) return;
|
||||
|
||||
const width = this.qualityRatio;
|
||||
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
|
||||
if (!Number.isFinite(height)) height = width;
|
||||
if (!height) return;
|
||||
|
||||
context.globalAlpha = 1;
|
||||
if (lastImageData) {
|
||||
const frameOffset = (1 / this.buffer) * (1000 / this.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);
|
||||
|
||||
lastImageData = context.getImageData(0, 0, width, height); // current image data
|
||||
|
||||
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 = this.qualityRatio;
|
||||
blurCanvas.height = Math.floor(newHeight / newWidth * this.qualityRatio);
|
||||
blurCanvas.style.width = `${newWidth * this.sizeRatio}px`;
|
||||
blurCanvas.style.height = `${newHeight * this.sizeRatio}px`;
|
||||
|
||||
if (this.isFullscreen) blurCanvas.classList.add('fullscreen');
|
||||
else blurCanvas.classList.remove('fullscreen');
|
||||
|
||||
const leftOffset = newWidth * (this.sizeRatio - 1) / 2;
|
||||
const topOffset = newHeight * (this.sizeRatio - 1) / 2;
|
||||
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
|
||||
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`);
|
||||
blurCanvas.style.setProperty('--blur', `${this.blur}px`);
|
||||
blurCanvas.style.setProperty('--opacity', `${this.opacity}`);
|
||||
};
|
||||
this.update = applyVideoAttributes;
|
||||
|
||||
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 / this.buffer)));
|
||||
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 / this.buffer)));
|
||||
};
|
||||
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 isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||
if (isPageOpen) {
|
||||
this.unregister?.();
|
||||
this.unregister = injectBlurVideo() ?? null;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'attributes') {
|
||||
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||
if (isPageOpen) {
|
||||
this.unregister?.();
|
||||
this.unregister = injectBlurVideo() ?? null;
|
||||
} else {
|
||||
this.unregister?.();
|
||||
this.unregister = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (playerPage) {
|
||||
observer.observe(playerPage, { attributes: true });
|
||||
}
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
this.interpolationTime = newConfig.interpolationTime;
|
||||
this.buffer = newConfig.buffer;
|
||||
this.qualityRatio = newConfig.quality;
|
||||
this.sizeRatio = newConfig.size / 100;
|
||||
this.blur = newConfig.blur;
|
||||
this.opacity = newConfig.opacity;
|
||||
this.isFullscreen = newConfig.fullscreen;
|
||||
|
||||
this.update?.();
|
||||
},
|
||||
stop() {
|
||||
this.observer?.disconnect();
|
||||
this.update = null;
|
||||
this.unregister?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
|
||||
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
|
||||
const sizeList = [100, 110, 125, 150, 175, 200, 300];
|
||||
const bufferList = [1, 5, 10, 20, 30];
|
||||
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
|
||||
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Smoothness transition',
|
||||
submenu: interpolationTimeList.map((interpolationTime) => ({
|
||||
label: `During ${interpolationTime / 1000}s`,
|
||||
type: 'radio',
|
||||
checked: config.interpolationTime === interpolationTime,
|
||||
click() {
|
||||
setConfig({ interpolationTime });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Quality',
|
||||
submenu: qualityList.map((quality) => ({
|
||||
label: `${quality} pixels`,
|
||||
type: 'radio',
|
||||
checked: config.quality === quality,
|
||||
click() {
|
||||
setConfig({ quality });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Size',
|
||||
submenu: sizeList.map((size) => ({
|
||||
label: `${size}%`,
|
||||
type: 'radio',
|
||||
checked: config.size === size,
|
||||
click() {
|
||||
setConfig({ size });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Buffer',
|
||||
submenu: bufferList.map((buffer) => ({
|
||||
label: `${buffer}`,
|
||||
type: 'radio',
|
||||
checked: config.buffer === buffer,
|
||||
click() {
|
||||
setConfig({ buffer });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Opacity',
|
||||
submenu: opacityList.map((opacity) => ({
|
||||
label: `${opacity * 100}%`,
|
||||
type: 'radio',
|
||||
checked: config.opacity === opacity,
|
||||
click() {
|
||||
setConfig({ opacity });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Blur amount',
|
||||
submenu: blurAmountList.map((blur) => ({
|
||||
label: `${blur} pixels`,
|
||||
type: 'radio',
|
||||
checked: config.blur === blur,
|
||||
click() {
|
||||
setConfig({ blur });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Using fullscreen',
|
||||
type: 'checkbox',
|
||||
checked: config.fullscreen,
|
||||
click(item) {
|
||||
setConfig({ fullscreen: item.checked });
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,184 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(async ({ getConfig }) => {
|
||||
const initConfigData = await getConfig();
|
||||
|
||||
let interpolationTime = initConfigData.interpolationTime;
|
||||
let buffer = initConfigData.buffer;
|
||||
let qualityRatio =initConfigData.quality;
|
||||
let sizeRatio = initConfigData.size / 100;
|
||||
let blur = initConfigData.blur;
|
||||
let opacity = initConfigData.opacity;
|
||||
let isFullscreen = initConfigData.fullscreen;
|
||||
|
||||
let unregister: (() => void) | null = null;
|
||||
let update: (() => void) | null = null;
|
||||
let observer: MutationObserver;
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
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(() => {
|
||||
// console.log('context', context);
|
||||
if (!context) return;
|
||||
|
||||
const width = qualityRatio;
|
||||
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
|
||||
if (!Number.isFinite(height)) height = width;
|
||||
if (!height) return;
|
||||
|
||||
context.globalAlpha = 1;
|
||||
if (lastImageData) {
|
||||
const frameOffset = (1 / buffer) * (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);
|
||||
|
||||
lastImageData = context.getImageData(0, 0, width, height); // current image data
|
||||
|
||||
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 * sizeRatio}px`;
|
||||
blurCanvas.style.height = `${newHeight * sizeRatio}px`;
|
||||
|
||||
if (isFullscreen) blurCanvas.classList.add('fullscreen');
|
||||
else blurCanvas.classList.remove('fullscreen');
|
||||
|
||||
const leftOffset = newWidth * (sizeRatio - 1) / 2;
|
||||
const topOffset = newHeight * (sizeRatio - 1) / 2;
|
||||
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
|
||||
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`);
|
||||
blurCanvas.style.setProperty('--blur', `${blur}px`);
|
||||
blurCanvas.style.setProperty('--opacity', `${opacity}`);
|
||||
};
|
||||
update = applyVideoAttributes;
|
||||
|
||||
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 / buffer)));
|
||||
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 / buffer)));
|
||||
};
|
||||
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 isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||
if (isPageOpen) {
|
||||
unregister?.();
|
||||
unregister = injectBlurVideo() ?? null;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
interpolationTime = newConfig.interpolationTime;
|
||||
buffer = newConfig.buffer;
|
||||
qualityRatio = newConfig.quality;
|
||||
sizeRatio = newConfig.size / 100;
|
||||
blur = newConfig.blur;
|
||||
opacity = newConfig.opacity;
|
||||
isFullscreen = newConfig.fullscreen;
|
||||
|
||||
update?.();
|
||||
},
|
||||
onUnload() {
|
||||
observer?.disconnect();
|
||||
update = null;
|
||||
unregister?.();
|
||||
}
|
||||
};
|
||||
});
|
||||
25
src/plugins/audio-compressor.ts
Normal file
25
src/plugins/audio-compressor.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Audio Compressor',
|
||||
description: '',
|
||||
|
||||
renderer() {
|
||||
document.addEventListener(
|
||||
'audioCanPlay',
|
||||
({ detail: { audioSource, audioContext } }) => {
|
||||
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;
|
||||
|
||||
audioSource.connect(compressor);
|
||||
compressor.connect(audioContext.destination);
|
||||
},
|
||||
{ once: true, passive: true },
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -1,17 +0,0 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
const builder = createPluginBuilder('audio-compressor', {
|
||||
name: 'Audio Compressor',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => {
|
||||
return {
|
||||
onLoad() {
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,20 +1,8 @@
|
||||
import { createPlugin } from '@/utils';
|
||||
import style from './style.css?inline';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
const builder = createPluginBuilder('blur-nav-bar', {
|
||||
export default createPlugin({
|
||||
name: 'Blur Navigation Bar',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
styles: [style],
|
||||
stylesheets: [style],
|
||||
renderer() {},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,9 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
const builder = createPluginBuilder('bypass-age-restrictions', {
|
||||
export default createPlugin({
|
||||
name: 'Bypass Age Restrictions',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
|
||||
renderer: () => import('simple-youtube-age-restriction-bypass'),
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => ({
|
||||
async onLoad() {
|
||||
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
|
||||
await import('simple-youtube-age-restriction-bypass');
|
||||
},
|
||||
}));
|
||||
@ -1,20 +1,180 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
const builder = createPluginBuilder('captions-selector', {
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||
|
||||
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
|
||||
|
||||
import { YoutubePlayer } from '@/types/youtube-player';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface CaptionsSelectorConfig {
|
||||
enabled: boolean;
|
||||
disableCaptions: boolean;
|
||||
autoload: boolean;
|
||||
lastCaptionsCode: string;
|
||||
}
|
||||
|
||||
export default createPlugin<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
captionsSettingsButton: HTMLElement;
|
||||
captionTrackList: LanguageOptions[] | null;
|
||||
api: YoutubePlayer | null;
|
||||
config: CaptionsSelectorConfig | null;
|
||||
setConfig: ((config: Partial<CaptionsSelectorConfig>) => void);
|
||||
videoChangeListener: (() => void);
|
||||
captionsButtonClickListener: (() => void);
|
||||
},
|
||||
CaptionsSelectorConfig
|
||||
>({
|
||||
name: 'Captions Selector',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
disableCaptions: false,
|
||||
autoload: false,
|
||||
lastCaptionsCode: '',
|
||||
},
|
||||
|
||||
async menu({ getConfig, setConfig }) {
|
||||
const config = await getConfig();
|
||||
return [
|
||||
{
|
||||
label: 'Automatically select last used caption',
|
||||
type: 'checkbox',
|
||||
checked: config.autoload as boolean,
|
||||
click(item) {
|
||||
setConfig({ autoload: item.checked });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'No captions by default',
|
||||
type: 'checkbox',
|
||||
checked: config.disableCaptions as boolean,
|
||||
click(item) {
|
||||
setConfig({ disableCaptions: item.checked });
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
backend: {
|
||||
start({ ipc: { handle }, window }) {
|
||||
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(),
|
||||
},
|
||||
window,
|
||||
),
|
||||
);
|
||||
},
|
||||
stop({ ipc: { removeHandler } }) {
|
||||
removeHandler('captionsSelector');
|
||||
}
|
||||
},
|
||||
|
||||
renderer: {
|
||||
captionsSettingsButton: ElementFromHtml(CaptionsSettingsButtonHTML),
|
||||
captionTrackList: null,
|
||||
api: null,
|
||||
config: null,
|
||||
setConfig: () => {},
|
||||
async captionsButtonClickListener() {
|
||||
if (this.captionTrackList?.length) {
|
||||
const currentCaptionTrack = this.api!.getOption<LanguageOptions>('captions', 'track');
|
||||
let currentIndex = currentCaptionTrack
|
||||
? this.captionTrackList.indexOf(this.captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!)
|
||||
: null;
|
||||
|
||||
const captionLabels = [
|
||||
...this.captionTrackList.map((track) => track.displayName),
|
||||
'None',
|
||||
];
|
||||
|
||||
currentIndex = await window.ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
|
||||
if (currentIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCaptions = this.captionTrackList[currentIndex];
|
||||
this.setConfig({ lastCaptionsCode: newCaptions?.languageCode });
|
||||
if (newCaptions) {
|
||||
this.api?.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
|
||||
} else {
|
||||
this.api?.setOption('captions', 'track', {});
|
||||
}
|
||||
|
||||
setTimeout(() => this.api?.playVideo());
|
||||
}
|
||||
},
|
||||
videoChangeListener() {
|
||||
if (this.config?.disableCaptions) {
|
||||
setTimeout(() => this.api!.unloadModule('captions'), 100);
|
||||
this.captionsSettingsButton.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this.api!.loadModule('captions');
|
||||
|
||||
setTimeout(() => {
|
||||
this.captionTrackList = this.api!.getOption('captions', 'tracklist') ?? [];
|
||||
|
||||
if (this.config!.autoload && this.config!.lastCaptionsCode) {
|
||||
this.api?.setOption('captions', 'track', {
|
||||
languageCode: this.config!.lastCaptionsCode,
|
||||
});
|
||||
}
|
||||
|
||||
this.captionsSettingsButton.style.display = this.captionTrackList?.length
|
||||
? 'inline-block'
|
||||
: 'none';
|
||||
}, 250);
|
||||
},
|
||||
async start({ getConfig, setConfig }) {
|
||||
this.config = await getConfig();
|
||||
this.setConfig = setConfig;
|
||||
},
|
||||
stop() {
|
||||
document.querySelector('.right-controls-buttons')?.removeChild(this.captionsSettingsButton);
|
||||
document.querySelector<YoutubePlayer & HTMLElement>('#movie_player')?.unloadModule('captions');
|
||||
document.querySelector('video')?.removeEventListener('srcChanged', this.videoChangeListener);
|
||||
this.captionsSettingsButton.removeEventListener('click', this.captionsButtonClickListener);
|
||||
},
|
||||
onPlayerApiReady(playerApi) {
|
||||
this.api = playerApi;
|
||||
|
||||
document.querySelector('.right-controls-buttons')?.append(this.captionsSettingsButton);
|
||||
|
||||
this.captionTrackList = this.api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
|
||||
|
||||
document.querySelector('video')?.addEventListener('srcChanged', this.videoChangeListener);
|
||||
this.captionsSettingsButton.addEventListener('click', this.captionsButtonClickListener);
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
this.config = newConfig;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
|
||||
export default builder.createMain(({ handle }) => ({
|
||||
onLoad(window) {
|
||||
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(),
|
||||
},
|
||||
window,
|
||||
));
|
||||
}
|
||||
}));
|
||||
@ -1,26 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
import type { MenuTemplate } from '../../menu';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Automatically select last used caption',
|
||||
type: 'checkbox',
|
||||
checked: config.autoload,
|
||||
click(item) {
|
||||
setConfig({ autoload: item.checked });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'No captions by default',
|
||||
type: 'checkbox',
|
||||
checked: config.disableCaptions,
|
||||
click(item) {
|
||||
setConfig({ disableCaptions: item.checked });
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,110 +0,0 @@
|
||||
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
|
||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
|
||||
|
||||
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
|
||||
|
||||
export default builder.createRenderer(({ getConfig, setConfig }) => {
|
||||
let config: Awaited<ReturnType<typeof getConfig>>;
|
||||
let captionTrackList: LanguageOptions[] | null = null;
|
||||
let api: YoutubePlayer;
|
||||
|
||||
const videoChangeListener = () => {
|
||||
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);
|
||||
};
|
||||
|
||||
const captionsButtonClickListener = 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 window.ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
|
||||
if (currentIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCaptions = captionTrackList[currentIndex];
|
||||
setConfig({ lastCaptionsCode: newCaptions?.languageCode });
|
||||
if (newCaptions) {
|
||||
api.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
|
||||
} else {
|
||||
api.setOption('captions', 'track', {});
|
||||
}
|
||||
|
||||
setTimeout(() => api.playVideo());
|
||||
}
|
||||
};
|
||||
|
||||
const removeListener = () => {
|
||||
$('.right-controls-buttons').removeChild(captionsSettingsButton);
|
||||
$<YoutubePlayer & HTMLElement>('#movie_player').unloadModule('captions');
|
||||
};
|
||||
|
||||
return {
|
||||
async onLoad() {
|
||||
config = await getConfig();
|
||||
},
|
||||
onPlayerApiReady(playerApi) {
|
||||
api = playerApi;
|
||||
|
||||
$('.right-controls-buttons').append(captionsSettingsButton);
|
||||
|
||||
captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
|
||||
|
||||
$('video').addEventListener('srcChanged', videoChangeListener);
|
||||
captionsSettingsButton.addEventListener('click', captionsButtonClickListener);
|
||||
},
|
||||
onUnload() {
|
||||
removeListener();
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,17 +1,38 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
const builder = createPluginBuilder('compact-sidebar', {
|
||||
export default createPlugin<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
getCompactSidebar: () => HTMLElement | null;
|
||||
isCompactSidebarDisabled: () => boolean;
|
||||
}
|
||||
>({
|
||||
name: 'Compact Sidebar',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
renderer: {
|
||||
getCompactSidebar: () => document.querySelector('#mini-guide'),
|
||||
isCompactSidebarDisabled() {
|
||||
const compactSidebar = this.getCompactSidebar();
|
||||
return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none';
|
||||
},
|
||||
start() {
|
||||
if (this.isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
if (this.isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
},
|
||||
onConfigChange() {
|
||||
if (this.isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => {
|
||||
const getCompactSidebar = () => document.querySelector('#mini-guide');
|
||||
const isCompactSidebarDisabled = () => {
|
||||
const compactSidebar = getCompactSidebar();
|
||||
return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none';
|
||||
};
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
if (isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
if (!isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
},
|
||||
onConfigChange() {
|
||||
if (isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,4 +1,16 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { Innertube } from 'youtubei.js';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { Howl } from 'howler';
|
||||
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { VolumeFader } from './fader';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
|
||||
export type CrossfadePluginConfig = {
|
||||
enabled: boolean;
|
||||
@ -8,7 +20,15 @@ export type CrossfadePluginConfig = {
|
||||
fadeScaling: 'linear' | 'logarithmic' | number;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('crossfade', {
|
||||
export default createPlugin<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
config: CrossfadePluginConfig | null;
|
||||
ipc: RendererContext<CrossfadePluginConfig>['ipc'] | null;
|
||||
},
|
||||
CrossfadePluginConfig
|
||||
>({
|
||||
name: 'Crossfade [beta]',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
@ -38,13 +58,241 @@ const builder = createPluginBuilder('crossfade', {
|
||||
* @default 'linear'
|
||||
*/
|
||||
fadeScaling: 'linear',
|
||||
} as CrossfadePluginConfig,
|
||||
});
|
||||
},
|
||||
menu({ window, getConfig, setConfig }) {
|
||||
const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
|
||||
const res = await prompt(
|
||||
{
|
||||
title: 'Crossfade Options',
|
||||
type: 'multiInput',
|
||||
multiInputOptions: [
|
||||
{
|
||||
label: 'Fade in duration (ms)',
|
||||
value: options.fadeInDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Fade out duration (ms)',
|
||||
value: options.fadeOutDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Crossfade x seconds before end',
|
||||
value:
|
||||
options.secondsBeforeEnd,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Fade scaling',
|
||||
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
|
||||
value: options.fadeScaling,
|
||||
},
|
||||
],
|
||||
resizable: true,
|
||||
height: 360,
|
||||
...promptOptions(),
|
||||
},
|
||||
win,
|
||||
).catch(console.error);
|
||||
|
||||
export default builder;
|
||||
if (!res) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
let fadeScaling: 'linear' | 'logarithmic' | number;
|
||||
if (res[3] === 'linear' || res[3] === 'logarithmic') {
|
||||
fadeScaling = res[3];
|
||||
} else if (isFinite(Number(res[3]))) {
|
||||
fadeScaling = Number(res[3]);
|
||||
} else {
|
||||
fadeScaling = options.fadeScaling;
|
||||
}
|
||||
|
||||
return {
|
||||
fadeInDuration: Number(res[0]),
|
||||
fadeOutDuration: Number(res[1]),
|
||||
secondsBeforeEnd: Number(res[2]),
|
||||
fadeScaling,
|
||||
};
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Advanced',
|
||||
async click() {
|
||||
const newOptions = await promptCrossfadeValues(window, await getConfig());
|
||||
if (newOptions) {
|
||||
setConfig(newOptions);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
async backend({ ipc }) {
|
||||
const yt = await Innertube.create({
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
|
||||
ipc.handle('audio-url', async (videoID: string) => {
|
||||
const info = await yt.getBasicInfo(videoID);
|
||||
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||
});
|
||||
},
|
||||
|
||||
renderer: {
|
||||
config: null,
|
||||
ipc: null,
|
||||
|
||||
start({ ipc }) {
|
||||
this.ipc = ipc;
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
this.config = newConfig;
|
||||
},
|
||||
onPlayerApiReady() {
|
||||
let transitionAudio: Howl; // Howler audio used to fade out the current music
|
||||
let firstVideo = true;
|
||||
let waitForTransition: Promise<unknown>;
|
||||
|
||||
const getStreamURL = async (videoID: string): Promise<string> => this.ipc?.invoke('audio-url', videoID);
|
||||
|
||||
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: this.config?.fadeScaling,
|
||||
fadeDuration: this.config?.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 - this.config!.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 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: this.config?.fadeScaling,
|
||||
fadeDuration: this.config?.fadeOutDuration,
|
||||
});
|
||||
|
||||
// Fade out the music
|
||||
video.volume = 0;
|
||||
fader.fadeOut(() => {
|
||||
resolveTransition();
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
watchVideoIDChanges(async (videoID) => {
|
||||
await waitForTransition;
|
||||
const url = await getStreamURL(videoID);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
createAudioForCrossfade(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { Innertube } from 'youtubei.js';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import { getNetFetchAsFetch } from '../utils/main';
|
||||
|
||||
export default builder.createMain(({ handle }) => ({
|
||||
async onLoad() {
|
||||
const yt = await Innertube.create({
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
|
||||
handle('audio-url', async (_, videoID: string) => {
|
||||
const info = await yt.getBasicInfo(videoID);
|
||||
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||
});
|
||||
}
|
||||
}));
|
||||
@ -1,91 +0,0 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import builder, { CrossfadePluginConfig } from './index';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
|
||||
export default builder.createMenu(({ window, getConfig, setConfig }) => {
|
||||
const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
|
||||
const res = await prompt(
|
||||
{
|
||||
title: 'Crossfade Options',
|
||||
type: 'multiInput',
|
||||
multiInputOptions: [
|
||||
{
|
||||
label: 'Fade in duration (ms)',
|
||||
value: options.fadeInDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Fade out duration (ms)',
|
||||
value: options.fadeOutDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Crossfade x seconds before end',
|
||||
value:
|
||||
options.secondsBeforeEnd,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Fade scaling',
|
||||
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
|
||||
value: options.fadeScaling,
|
||||
},
|
||||
],
|
||||
resizable: true,
|
||||
height: 360,
|
||||
...promptOptions(),
|
||||
},
|
||||
win,
|
||||
).catch(console.error);
|
||||
|
||||
if (!res) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let fadeScaling: 'linear' | 'logarithmic' | number;
|
||||
if (res[3] === 'linear' || res[3] === 'logarithmic') {
|
||||
fadeScaling = res[3];
|
||||
} else if (isFinite(Number(res[3]))) {
|
||||
fadeScaling = Number(res[3]);
|
||||
} else {
|
||||
fadeScaling = options.fadeScaling;
|
||||
}
|
||||
|
||||
return {
|
||||
fadeInDuration: Number(res[0]),
|
||||
fadeOutDuration: Number(res[1]),
|
||||
secondsBeforeEnd: Number(res[2]),
|
||||
fadeScaling,
|
||||
};
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Advanced',
|
||||
async click() {
|
||||
const newOptions = await promptCrossfadeValues(window, await getConfig());
|
||||
if (newOptions) {
|
||||
setConfig(newOptions);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,153 +0,0 @@
|
||||
import { Howl } from 'howler';
|
||||
|
||||
// Extracted from https://github.com/bitfasching/VolumeFader
|
||||
import { VolumeFader } from './fader';
|
||||
|
||||
import builder, { CrossfadePluginConfig } from './index';
|
||||
|
||||
export default builder.createRenderer(({ getConfig, invoke }) => {
|
||||
let config: CrossfadePluginConfig;
|
||||
|
||||
let transitionAudio: Howl; // Howler audio used to fade out the current music
|
||||
let firstVideo = true;
|
||||
let waitForTransition: Promise<unknown>;
|
||||
|
||||
const getStreamURL = async (videoID: string): Promise<string> => invoke('audio-url', videoID);
|
||||
|
||||
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: config.fadeScaling,
|
||||
fadeDuration: config.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 - config.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;
|
||||
}
|
||||
|
||||
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: config.fadeScaling,
|
||||
fadeDuration: config.fadeOutDuration,
|
||||
});
|
||||
|
||||
// Fade out the music
|
||||
video.volume = 0;
|
||||
fader.fadeOut(() => {
|
||||
resolveTransition();
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
async onLoad() {
|
||||
config = await getConfig();
|
||||
},
|
||||
onPlayerApiReady() {
|
||||
onApiLoaded();
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -1,23 +1,74 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import {YoutubePlayer} from "@/types/youtube-player";
|
||||
|
||||
export type DisableAutoPlayPluginConfig = {
|
||||
enabled: boolean;
|
||||
applyOnce: boolean;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('disable-autoplay', {
|
||||
export default createPlugin<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
config: DisableAutoPlayPluginConfig | null;
|
||||
api: YoutubePlayer | null;
|
||||
eventListener: (name: string) => void;
|
||||
timeUpdateListener: (e: Event) => void;
|
||||
},
|
||||
DisableAutoPlayPluginConfig
|
||||
>({
|
||||
name: 'Disable Autoplay',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
applyOnce: false,
|
||||
} as DisableAutoPlayPluginConfig,
|
||||
},
|
||||
menu: async ({ getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Applies only on startup',
|
||||
type: 'checkbox',
|
||||
checked: config.applyOnce,
|
||||
async click() {
|
||||
const nowConfig = await getConfig();
|
||||
setConfig({
|
||||
applyOnce: !nowConfig.applyOnce,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
renderer: {
|
||||
config: null,
|
||||
api: null,
|
||||
eventListener(name: string) {
|
||||
if (this.config?.applyOnce) {
|
||||
this.api?.removeEventListener('videodatachange', this.eventListener);
|
||||
}
|
||||
|
||||
if (name === 'dataloaded') {
|
||||
this.api?.pauseVideo();
|
||||
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', this.timeUpdateListener, { once: true });
|
||||
}
|
||||
},
|
||||
timeUpdateListener(e: Event) {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
e.target.pause();
|
||||
}
|
||||
},
|
||||
onPlayerApiReady(api) {
|
||||
this.api = api;
|
||||
|
||||
api.addEventListener('videodatachange', this.eventListener);
|
||||
},
|
||||
stop() {
|
||||
this.api?.removeEventListener('videodatachange', this.eventListener);
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
this.config = newConfig;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Applies only on startup',
|
||||
type: 'checkbox',
|
||||
checked: config.applyOnce,
|
||||
async click() {
|
||||
const nowConfig = await getConfig();
|
||||
setConfig({
|
||||
applyOnce: !nowConfig.applyOnce,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,42 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
||||
|
||||
export default builder.createRenderer(({ getConfig }) => {
|
||||
let config: Awaited<ReturnType<typeof getConfig>>;
|
||||
|
||||
let apiEvent: YoutubePlayer;
|
||||
|
||||
const timeUpdateListener = (e: Event) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
e.target.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const eventListener = async (name: string) => {
|
||||
if (config.applyOnce) {
|
||||
apiEvent.removeEventListener('videodatachange', eventListener);
|
||||
}
|
||||
|
||||
if (name === 'dataloaded') {
|
||||
apiEvent.pauseVideo();
|
||||
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async onPlayerApiReady(api) {
|
||||
config = await getConfig();
|
||||
|
||||
apiEvent = api;
|
||||
|
||||
apiEvent.addEventListener('videodatachange', eventListener);
|
||||
},
|
||||
onUnload() {
|
||||
apiEvent.removeEventListener('videodatachange', eventListener);
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,4 +1,6 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onLoad, onUnload } from './main';
|
||||
import { onMenu } from './menu';
|
||||
|
||||
export type DiscordPluginConfig = {
|
||||
enabled: boolean;
|
||||
@ -32,7 +34,7 @@ export type DiscordPluginConfig = {
|
||||
hideDurationLeft: boolean;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('discord', {
|
||||
export default createPlugin({
|
||||
name: 'Discord Rich Presence',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
@ -44,12 +46,12 @@ const builder = createPluginBuilder('discord', {
|
||||
hideGitHubButton: false,
|
||||
hideDurationLeft: false,
|
||||
} as DiscordPluginConfig,
|
||||
menu: onMenu,
|
||||
backend: {
|
||||
async start({ window, getConfig }) {
|
||||
await onLoad(window, await getConfig());
|
||||
},
|
||||
stop: onUnload,
|
||||
}
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,9 +4,10 @@ import { dev } from 'electron-is';
|
||||
|
||||
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
|
||||
|
||||
import builder from './index';
|
||||
import registerCallback, { type SongInfoCallback, type SongInfo } from '@/providers/song-info';
|
||||
|
||||
import type { DiscordPluginConfig } from './index';
|
||||
|
||||
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
|
||||
|
||||
// Application ID registered by @Zo-Bro-23
|
||||
const clientId = '1043858434585526382';
|
||||
@ -92,135 +93,6 @@ export const connect = (showError = false) => {
|
||||
|
||||
let clearActivity: NodeJS.Timeout | undefined;
|
||||
let updateActivity: SongInfoCallback;
|
||||
|
||||
export default builder.createMain(({ getConfig }) => {
|
||||
return {
|
||||
async onLoad(win) {
|
||||
const options = await getConfig();
|
||||
|
||||
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.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', 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;
|
||||
if (lastSongInfo) {
|
||||
lastSongInfo.elapsedSeconds = t;
|
||||
updateActivity(lastSongInfo);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
app.on('window-all-closed', clear);
|
||||
},
|
||||
onUnload() {
|
||||
resetInfo();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const clear = () => {
|
||||
if (info.rpc) {
|
||||
info.rpc.user?.clearActivity();
|
||||
@ -231,3 +103,127 @@ export const clear = () => {
|
||||
|
||||
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
|
||||
export const isConnected = () => info.rpc !== null;
|
||||
|
||||
export const onLoad = async (win: Electron.BrowserWindow, options: DiscordPluginConfig) => {
|
||||
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.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', 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;
|
||||
if (lastSongInfo) {
|
||||
lastSongInfo.elapsedSeconds = t;
|
||||
updateActivity(lastSongInfo);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
app.on('window-all-closed', clear);
|
||||
};
|
||||
|
||||
export const onUnload = () => {
|
||||
resetInfo();
|
||||
};
|
||||
|
||||
|
||||
@ -2,22 +2,20 @@ import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { clear, connect, isConnected, registerRefresh } from './main';
|
||||
|
||||
import builder from './index';
|
||||
import { singleton } from '@/providers/decorators';
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
import { setMenuOptions } from '@/config/plugins';
|
||||
|
||||
import { setMenuOptions } from '../../config/plugins';
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
import { singleton } from '../../providers/decorators';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { DiscordPluginConfig } from './index';
|
||||
|
||||
import type { MenuTemplate } from '../../menu';
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
||||
registerRefresh(refreshMenu);
|
||||
});
|
||||
|
||||
type DiscordOptions = ConfigType<'discord'>;
|
||||
|
||||
export default builder.createMenu(async ({ window, getConfig, setConfig, refresh }): Promise<MenuTemplate> => {
|
||||
export const onMenu = async ({ window, getConfig, setConfig, refresh }: MenuContext<DiscordPluginConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
registerRefreshOnce(refresh);
|
||||
|
||||
@ -86,9 +84,9 @@ export default builder.createMenu(async ({ window, getConfig, setConfig, refresh
|
||||
click: () => setInactivityTimeout(window, config),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
|
||||
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordPluginConfig) {
|
||||
const output = await prompt({
|
||||
title: 'Set Inactivity Timeout',
|
||||
label: 'Enter inactivity timeout in seconds:',
|
||||
|
||||
@ -2,7 +2,9 @@ import { DefaultPresetList, Preset } from './types';
|
||||
|
||||
import style from './style.css?inline';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onConfigChange, onMainLoad } from './main';
|
||||
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||
|
||||
export type DownloaderPluginConfig = {
|
||||
enabled: boolean;
|
||||
@ -13,24 +15,27 @@ export type DownloaderPluginConfig = {
|
||||
playlistMaxItems?: number;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('downloader', {
|
||||
export const defaultConfig: DownloaderPluginConfig = {
|
||||
enabled: false,
|
||||
downloadFolder: undefined,
|
||||
selectedPreset: 'mp3 (256kbps)', // Selected preset
|
||||
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
|
||||
skipExisting: false,
|
||||
playlistMaxItems: undefined,
|
||||
};
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Downloader',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
downloadFolder: undefined,
|
||||
selectedPreset: 'mp3 (256kbps)', // Selected preset
|
||||
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
|
||||
skipExisting: false,
|
||||
playlistMaxItems: undefined,
|
||||
} as DownloaderPluginConfig,
|
||||
styles: [style],
|
||||
config: defaultConfig,
|
||||
stylesheets: [style],
|
||||
backend: {
|
||||
start: onMainLoad,
|
||||
onConfigChange,
|
||||
},
|
||||
renderer: {
|
||||
start: onRendererLoad,
|
||||
onPlayerApiReady,
|
||||
}
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,15 +28,17 @@ import {
|
||||
setBadge,
|
||||
} from './utils';
|
||||
|
||||
import { fetchFromGenius } from '@/plugins/lyrics-genius/main';
|
||||
import { isEnabled } from '@/config/plugins';
|
||||
import { cleanupName, getImage, SongInfo } from '@/providers/song-info';
|
||||
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
||||
import { cache } from '@/providers/decorators';
|
||||
|
||||
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
|
||||
|
||||
import builder, { DownloaderPluginConfig } from '../index';
|
||||
import type { DownloaderPluginConfig } from '../index';
|
||||
|
||||
import { fetchFromGenius } from '../../lyrics-genius/main';
|
||||
import { isEnabled } from '../../../config/plugins';
|
||||
import { cleanupName, getImage, SongInfo } from '../../../providers/song-info';
|
||||
import { getNetFetchAsFetch } from '../../utils/main';
|
||||
import { cache } from '../../../providers/decorators';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
||||
@ -44,7 +46,7 @@ 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';
|
||||
import type { GetPlayerResponse } from '@/types/get-player-response';
|
||||
|
||||
type CustomSongInfo = SongInfo & { trackId?: string };
|
||||
|
||||
@ -89,31 +91,28 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
|
||||
.join(';');
|
||||
};
|
||||
|
||||
let config: DownloaderPluginConfig = builder.config;
|
||||
let config: DownloaderPluginConfig;
|
||||
|
||||
export default builder.createMain(({ handle, getConfig, on }) => {
|
||||
return {
|
||||
async onLoad(_win) {
|
||||
win = _win;
|
||||
config = await getConfig();
|
||||
export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext<DownloaderPluginConfig>) => {
|
||||
win = _win;
|
||||
config = await getConfig();
|
||||
|
||||
yt = await Innertube.create({
|
||||
cache: new UniversalCache(false),
|
||||
cookie: await getCookieFromWindow(win),
|
||||
generate_session_locally: true,
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
handle('download-song', (url: string) => downloadSong(url));
|
||||
on('video-src-changed', (data: GetPlayerResponse) => {
|
||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||
});
|
||||
handle('download-playlist-request', async (_event, url: string) => downloadPlaylist(url));
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
};
|
||||
});
|
||||
yt = await Innertube.create({
|
||||
cache: new UniversalCache(false),
|
||||
cookie: await getCookieFromWindow(win),
|
||||
generate_session_locally: true,
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
ipc.handle('download-song', (url: string) => downloadSong(url));
|
||||
ipc.on('video-src-changed', (data: GetPlayerResponse) => {
|
||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||
});
|
||||
ipc.handle('download-playlist-request', async (url: string) => downloadPlaylist(url));
|
||||
};
|
||||
|
||||
export const onConfigChange = (newConfig: DownloaderPluginConfig) => {
|
||||
config = newConfig;
|
||||
};
|
||||
|
||||
export async function downloadSong(
|
||||
url: string,
|
||||
@ -319,7 +318,7 @@ async function iterableStreamToTargetFile(
|
||||
contentLength: number,
|
||||
sendFeedback: (str: string, value?: number) => void,
|
||||
increasePlaylistProgress: (value: number) => void = () => {},
|
||||
) {
|
||||
): Promise<Uint8Array | null> {
|
||||
const chunks = [];
|
||||
let downloaded = 0;
|
||||
for await (const chunk of stream) {
|
||||
@ -379,6 +378,7 @@ async function iterableStreamToTargetFile(
|
||||
} finally {
|
||||
releaseFFmpegMutex();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const getCoverBuffer = cache(async (url: string) => {
|
||||
|
||||
@ -4,9 +4,12 @@ import { downloadPlaylist } from './main';
|
||||
import { defaultMenuDownloadLabel, getFolder } from './main/utils';
|
||||
import { DefaultPresetList } from './types';
|
||||
|
||||
import builder from './index';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
import type { DownloaderPluginConfig } from './index';
|
||||
|
||||
export const onMenu = async ({ getConfig, setConfig }: MenuContext<DownloaderPluginConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
@ -46,4 +49,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import downloadHTML from './templates/download.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
import defaultConfig from '@/config/defaults';
|
||||
import { getSongMenu } from '@/providers/dom-elements';
|
||||
import { getSongInfo } from '@/providers/song-info-front';
|
||||
|
||||
import defaultConfig from '../../config/defaults';
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
import { getSongInfo } from '../../providers/song-info-front';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
|
||||
import type { DownloaderPluginConfig } from './index';
|
||||
|
||||
let menu: Element | null = null;
|
||||
let progress: Element | null = null;
|
||||
@ -13,70 +16,67 @@ const downloadButton = ElementFromHtml(downloadHTML);
|
||||
|
||||
let doneFirstLoad = false;
|
||||
|
||||
export default builder.createRenderer(({ invoke, on }) => {
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.contains(downloadButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(downloadButton);
|
||||
progress = document.querySelector('#ytmcustom-download');
|
||||
|
||||
if (doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||
});
|
||||
|
||||
export const onRendererLoad = ({ ipc }: RendererContext<DownloaderPluginConfig>) => {
|
||||
window.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=')) {
|
||||
ipc.invoke('download-playlist-request', videoUrl);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
videoUrl = getSongInfo().url || window.location.href;
|
||||
}
|
||||
|
||||
if (menu.contains(downloadButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(downloadButton);
|
||||
progress = document.querySelector('#ytmcustom-download');
|
||||
|
||||
if (doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||
});
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
window.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=')) {
|
||||
invoke('download-playlist-request', videoUrl);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
videoUrl = getSongInfo().url || window.location.href;
|
||||
}
|
||||
|
||||
invoke('download-song', videoUrl);
|
||||
};
|
||||
|
||||
on('downloader-feedback', (feedback: string) => {
|
||||
if (progress) {
|
||||
progress.innerHTML = feedback || 'Download';
|
||||
} else {
|
||||
console.warn('Cannot update progress');
|
||||
}
|
||||
});
|
||||
},
|
||||
onPlayerApiReady() {
|
||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
},
|
||||
ipc.invoke('download-song', videoUrl);
|
||||
};
|
||||
});
|
||||
|
||||
ipc.on('downloader-feedback', (feedback: string) => {
|
||||
if (progress) {
|
||||
progress.innerHTML = feedback || 'Download';
|
||||
} else {
|
||||
console.warn('Cannot update progress');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const onPlayerApiReady = () => {
|
||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,17 +1,50 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
const builder = createPluginBuilder('exponential-volume', {
|
||||
export default createPlugin({
|
||||
name: 'Exponential Volume',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
renderer: {
|
||||
onPlayerApiReady() {
|
||||
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
|
||||
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
|
||||
|
||||
export default builder;
|
||||
// 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;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
|
||||
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
|
||||
|
||||
import builder from './index';
|
||||
|
||||
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 builder.createRenderer(() => ({
|
||||
onPlayerApiReady() {
|
||||
exponentialVolume();
|
||||
},
|
||||
}));
|
||||
@ -1,21 +1,28 @@
|
||||
import titlebarStyle from './titlebar.css?inline';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onMainLoad } from './main';
|
||||
import { onMenu } from './menu';
|
||||
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
export interface InAppMenuConfig {
|
||||
enabled: boolean;
|
||||
hideDOMWindowControls: boolean;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('in-app-menu', {
|
||||
export default createPlugin({
|
||||
name: 'In-App Menu',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
hideDOMWindowControls: false,
|
||||
} as InAppMenuConfig,
|
||||
stylesheets: [titlebarStyle],
|
||||
menu: onMenu,
|
||||
|
||||
backend: onMainLoad,
|
||||
renderer: {
|
||||
start: onRendererLoad,
|
||||
onPlayerApiReady,
|
||||
},
|
||||
styles: [titlebarStyle],
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,75 +2,71 @@ import { register } from 'electron-localshortcut';
|
||||
|
||||
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron';
|
||||
|
||||
import builder from './index';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
import type { InAppMenuConfig } from './index';
|
||||
|
||||
export default builder.createMain(({ handle, send }) => {
|
||||
export const onMainLoad = ({ window: win, ipc: { handle, send } }: BackendContext<InAppMenuConfig>) => {
|
||||
win.on('close', () => {
|
||||
send('close-all-in-app-menu-panel');
|
||||
});
|
||||
|
||||
return {
|
||||
onLoad(win) {
|
||||
win.on('close', () => {
|
||||
send('close-all-in-app-menu-panel');
|
||||
});
|
||||
win.once('ready-to-show', () => {
|
||||
register(win, '`', () => {
|
||||
send('toggle-in-app-menu');
|
||||
});
|
||||
});
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
register(win, '`', () => {
|
||||
send('toggle-in-app-menu');
|
||||
});
|
||||
});
|
||||
handle(
|
||||
'get-menu',
|
||||
() => JSON.parse(JSON.stringify(
|
||||
Menu.getApplicationMenu(),
|
||||
(key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined),
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
handle('window-is-maximized', () => win.isMaximized());
|
||||
|
||||
handle('window-close', () => win.close());
|
||||
handle('window-minimize', () => win.minimize());
|
||||
handle('window-maximize', () => win.maximize());
|
||||
win.on('maximize', () => send('window-maximize'));
|
||||
handle('window-unmaximize', () => win.unmaximize());
|
||||
win.on('unmaximize', () => send('window-unmaximize'));
|
||||
|
||||
handle('image-path-to-data-url', (imagePath: string) => {
|
||||
const nativeImageIcon = nativeImage.createFromPath(imagePath);
|
||||
return nativeImageIcon?.toDataURL();
|
||||
});
|
||||
},
|
||||
return target;
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('menu-event', (event, commandId: number) => {
|
||||
const target = getMenuItemById(commandId);
|
||||
if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
handle('window-is-maximized', () => win.isMaximized());
|
||||
|
||||
handle('window-close', () => win.close());
|
||||
handle('window-minimize', () => win.minimize());
|
||||
handle('window-maximize', () => win.maximize());
|
||||
win.on('maximize', () => send('window-maximize'));
|
||||
handle('window-unmaximize', () => win.unmaximize());
|
||||
win.on('unmaximize', () => send('window-unmaximize'));
|
||||
|
||||
handle('image-path-to-data-url', (imagePath: string) => {
|
||||
const nativeImageIcon = nativeImage.createFromPath(imagePath);
|
||||
return nativeImageIcon?.toDataURL();
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import is from 'electron-is';
|
||||
|
||||
import builder from './index';
|
||||
import { setMenuOptions } from '@/config/plugins';
|
||||
|
||||
import { setMenuOptions } from '../../config/plugins';
|
||||
import type { InAppMenuConfig } from './index';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig }) => {
|
||||
export const onMenu = async ({ getConfig }: MenuContext<InAppMenuConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
if (is.linux()) {
|
||||
@ -22,4 +24,4 @@ export default builder.createMenu(async ({ getConfig }) => {
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
@ -6,190 +6,187 @@ import minimizeRaw from './assets/minimize.svg?inline';
|
||||
import maximizeRaw from './assets/maximize.svg?inline';
|
||||
import unmaximizeRaw from './assets/unmaximize.svg?inline';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import type { Menu } from 'electron';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { InAppMenuConfig } from '@/plugins/in-app-menu/index';
|
||||
|
||||
const isMacOS = navigator.userAgent.includes('Macintosh');
|
||||
const isNotWindowsOrMacOS = !navigator.userAgent.includes('Windows') && !isMacOS;
|
||||
|
||||
export default builder.createRenderer(({ getConfig, invoke, on }) => {
|
||||
return {
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
export const onRendererLoad = async ({ getConfig, ipc: { invoke, on } }: RendererContext<InAppMenuConfig>) => {
|
||||
const config = await getConfig();
|
||||
|
||||
const hideDOMWindowControls = config.hideDOMWindowControls;
|
||||
const hideDOMWindowControls = config.hideDOMWindowControls;
|
||||
|
||||
let hideMenu = window.mainConfig.get('options.hideMenu');
|
||||
const titleBar = document.createElement('title-bar');
|
||||
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
|
||||
let maximizeButton: HTMLButtonElement;
|
||||
let panelClosers: (() => void)[] = [];
|
||||
if (isMacOS) titleBar.style.setProperty('--offset-left', '70px');
|
||||
let hideMenu = window.mainConfig.get('options.hideMenu');
|
||||
const titleBar = document.createElement('title-bar');
|
||||
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
|
||||
let maximizeButton: HTMLButtonElement;
|
||||
let panelClosers: (() => void)[] = [];
|
||||
if (isMacOS) titleBar.style.setProperty('--offset-left', '70px');
|
||||
|
||||
const logo = document.createElement('img');
|
||||
const close = document.createElement('img');
|
||||
const minimize = document.createElement('img');
|
||||
const maximize = document.createElement('img');
|
||||
const unmaximize = document.createElement('img');
|
||||
const logo = document.createElement('img');
|
||||
const close = document.createElement('img');
|
||||
const minimize = document.createElement('img');
|
||||
const maximize = document.createElement('img');
|
||||
const unmaximize = document.createElement('img');
|
||||
|
||||
if (window.ELECTRON_RENDERER_URL) {
|
||||
logo.src = window.ELECTRON_RENDERER_URL + '/' + logoRaw;
|
||||
close.src = window.ELECTRON_RENDERER_URL + '/' + closeRaw;
|
||||
minimize.src = window.ELECTRON_RENDERER_URL + '/' + minimizeRaw;
|
||||
maximize.src = window.ELECTRON_RENDERER_URL + '/' + maximizeRaw;
|
||||
unmaximize.src = window.ELECTRON_RENDERER_URL + '/' + unmaximizeRaw;
|
||||
} else {
|
||||
logo.src = logoRaw;
|
||||
close.src = closeRaw;
|
||||
minimize.src = minimizeRaw;
|
||||
maximize.src = maximizeRaw;
|
||||
unmaximize.src = unmaximizeRaw;
|
||||
}
|
||||
if (window.ELECTRON_RENDERER_URL) {
|
||||
logo.src = window.ELECTRON_RENDERER_URL + '/' + logoRaw;
|
||||
close.src = window.ELECTRON_RENDERER_URL + '/' + closeRaw;
|
||||
minimize.src = window.ELECTRON_RENDERER_URL + '/' + minimizeRaw;
|
||||
maximize.src = window.ELECTRON_RENDERER_URL + '/' + maximizeRaw;
|
||||
unmaximize.src = window.ELECTRON_RENDERER_URL + '/' + unmaximizeRaw;
|
||||
} else {
|
||||
logo.src = logoRaw;
|
||||
close.src = closeRaw;
|
||||
minimize.src = minimizeRaw;
|
||||
maximize.src = maximizeRaw;
|
||||
unmaximize.src = unmaximizeRaw;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
on('toggle-in-app-menu', 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 = () => invoke('window-minimize');
|
||||
|
||||
maximizeButton = document.createElement('button');
|
||||
if (await 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 invoke('window-is-maximized')) {
|
||||
// change icon to maximize
|
||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
||||
maximizeButton.appendChild(maximize);
|
||||
|
||||
// call unmaximize
|
||||
await invoke('window-unmaximize');
|
||||
} else {
|
||||
// change icon to unmaximize
|
||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
|
||||
// call maximize
|
||||
await invoke('window-maximize');
|
||||
}
|
||||
};
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.classList.add('window-control');
|
||||
closeButton.appendChild(close);
|
||||
closeButton.onclick = () => 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 && !hideDOMWindowControls) 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();
|
||||
});
|
||||
panelClosers = [];
|
||||
|
||||
const menu = await invoke<Menu | null>('get-menu');
|
||||
if (!menu) return;
|
||||
|
||||
menu.items.forEach((menuItem) => {
|
||||
const menu = document.createElement('menu-button');
|
||||
const [, { close: closer }] = createPanel(titleBar, menu, menuItem.submenu?.items ?? []);
|
||||
panelClosers.push(closer);
|
||||
|
||||
menu.append(menuItem.label);
|
||||
titleBar.appendChild(menu);
|
||||
if (hideMenu) {
|
||||
menu.style.visibility = 'hidden';
|
||||
}
|
||||
});
|
||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls();
|
||||
};
|
||||
await updateMenu();
|
||||
|
||||
document.title = 'Youtube Music';
|
||||
|
||||
on('close-all-in-app-menu-panel', () => {
|
||||
panelClosers.forEach((closer) => closer());
|
||||
});
|
||||
on('refresh-in-app-menu', () => updateMenu());
|
||||
on('window-maximize', () => {
|
||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
|
||||
maximizeButton.removeChild(maximizeButton.firstChild);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
}
|
||||
});
|
||||
on('window-unmaximize', () => {
|
||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
|
||||
maximizeButton.removeChild(maximizeButton.firstChild);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
}
|
||||
});
|
||||
|
||||
if (window.mainConfig.plugins.isEnabled('picture-in-picture')) {
|
||||
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)
|
||||
onPlayerApiReady() {
|
||||
const htmlHeadStyle = document.querySelector('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 {');
|
||||
}
|
||||
},
|
||||
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;
|
||||
|
||||
on('toggle-in-app-menu', 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 = () => invoke('window-minimize');
|
||||
|
||||
maximizeButton = document.createElement('button');
|
||||
if (await 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 invoke('window-is-maximized')) {
|
||||
// change icon to maximize
|
||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
||||
maximizeButton.appendChild(maximize);
|
||||
|
||||
// call unmaximize
|
||||
await invoke('window-unmaximize');
|
||||
} else {
|
||||
// change icon to unmaximize
|
||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
|
||||
// call maximize
|
||||
await invoke('window-maximize');
|
||||
}
|
||||
};
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.classList.add('window-control');
|
||||
closeButton.appendChild(close);
|
||||
closeButton.onclick = () => 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 && !hideDOMWindowControls) 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();
|
||||
});
|
||||
panelClosers = [];
|
||||
|
||||
const menu = await invoke('get-menu') as Menu | null;
|
||||
if (!menu) return;
|
||||
|
||||
menu.items.forEach((menuItem) => {
|
||||
const menu = document.createElement('menu-button');
|
||||
const [, { close: closer }] = createPanel(titleBar, menu, menuItem.submenu?.items ?? []);
|
||||
panelClosers.push(closer);
|
||||
|
||||
menu.append(menuItem.label);
|
||||
titleBar.appendChild(menu);
|
||||
if (hideMenu) {
|
||||
menu.style.visibility = 'hidden';
|
||||
}
|
||||
});
|
||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls();
|
||||
};
|
||||
await updateMenu();
|
||||
|
||||
document.title = 'Youtube Music';
|
||||
|
||||
on('close-all-in-app-menu-panel', () => {
|
||||
panelClosers.forEach((closer) => closer());
|
||||
});
|
||||
on('refresh-in-app-menu', () => updateMenu());
|
||||
on('window-maximize', () => {
|
||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
|
||||
maximizeButton.removeChild(maximizeButton.firstChild);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
}
|
||||
});
|
||||
on('window-unmaximize', () => {
|
||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
|
||||
maximizeButton.removeChild(maximizeButton.firstChild);
|
||||
maximizeButton.appendChild(unmaximize);
|
||||
}
|
||||
});
|
||||
|
||||
if (window.mainConfig.plugins.isEnabled('picture-in-picture')) {
|
||||
on('pip-toggle', () => {
|
||||
updateMenu();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const onPlayerApiReady = () => {
|
||||
const htmlHeadStyle = document.querySelector('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 {');
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import registerCallback from '@/providers/song-info';
|
||||
import { addScrobble, getAndSetSessionKey, setNowPlaying } from './main';
|
||||
|
||||
export interface LastFmPluginConfig {
|
||||
enabled: boolean;
|
||||
@ -30,7 +32,7 @@ export interface LastFmPluginConfig {
|
||||
secret: string;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('last-fm', {
|
||||
export default createPlugin({
|
||||
name: 'Last.fm',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
@ -39,12 +41,34 @@ const builder = createPluginBuilder('last-fm', {
|
||||
api_key: '04d76faaac8726e60988e14c105d421a',
|
||||
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
||||
} as LastFmPluginConfig,
|
||||
});
|
||||
async backend({ getConfig, setConfig }) {
|
||||
let config = await getConfig();
|
||||
// This will store the timeout that will trigger addScrobble
|
||||
let scrobbleTimer: number | undefined;
|
||||
|
||||
export default builder;
|
||||
if (!config.api_root) {
|
||||
config.enabled = true;
|
||||
setConfig(config);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
if (!config.session_key) {
|
||||
// Not authenticated
|
||||
config = await getAndSetSessionKey(config, setConfig);
|
||||
}
|
||||
|
||||
registerCallback((songInfo) => {
|
||||
// Set remove the old scrobble timer
|
||||
clearTimeout(scrobbleTimer);
|
||||
if (!songInfo.isPaused) {
|
||||
setNowPlaying(songInfo, config, setConfig);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -2,10 +2,8 @@ import crypto from 'node:crypto';
|
||||
|
||||
import { net, shell } from 'electron';
|
||||
|
||||
import builder, { type LastFmPluginConfig } from './index';
|
||||
|
||||
import { setOptions } from '../../config/plugins';
|
||||
import registerCallback, { type SongInfo } from '../../providers/song-info';
|
||||
import type { LastFmPluginConfig } from './index';
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
|
||||
interface LastFmData {
|
||||
method: string,
|
||||
@ -53,7 +51,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
||||
keys.sort();
|
||||
let sig = '';
|
||||
for (const key of keys) {
|
||||
if (String(key) === 'format') {
|
||||
if (key === 'format') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -83,7 +81,9 @@ const authenticate = async (config: LastFmPluginConfig) => {
|
||||
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
||||
};
|
||||
|
||||
const getAndSetSessionKey = async (config: LastFmPluginConfig) => {
|
||||
type SetConfType = (conf: Partial<Omit<LastFmPluginConfig, 'enabled'>>) => (void | Promise<void>);
|
||||
|
||||
export const getAndSetSessionKey = async (config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||
// Get and store the session key
|
||||
const data = {
|
||||
api_key: config.api_key,
|
||||
@ -102,19 +102,19 @@ const getAndSetSessionKey = async (config: LastFmPluginConfig) => {
|
||||
if (json.error) {
|
||||
config.token = await createToken(config);
|
||||
await authenticate(config);
|
||||
setOptions('last-fm', config);
|
||||
setConfig(config);
|
||||
}
|
||||
if (json.session) {
|
||||
config.session_key = json.session.key;
|
||||
}
|
||||
setOptions('last-fm', config);
|
||||
setConfig(config);
|
||||
return config;
|
||||
};
|
||||
|
||||
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData) => {
|
||||
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData, setConfig: SetConfType) => {
|
||||
// This sends a post request to the api, and adds the common data
|
||||
if (!config.session_key) {
|
||||
await getAndSetSessionKey(config);
|
||||
await getAndSetSessionKey(config, setConfig);
|
||||
}
|
||||
|
||||
const postData: LastFmSongData = {
|
||||
@ -143,58 +143,24 @@ const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig,
|
||||
config.session_key = undefined;
|
||||
config.token = await createToken(config);
|
||||
await authenticate(config);
|
||||
setOptions('last-fm', config);
|
||||
setConfig(config);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig) => {
|
||||
export const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||
// 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);
|
||||
postSongDataToAPI(songInfo, config, data, setConfig);
|
||||
};
|
||||
|
||||
const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig) => {
|
||||
export const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||
// This sets the now playing status in last.fm
|
||||
const data = {
|
||||
method: 'track.updateNowPlaying',
|
||||
};
|
||||
postSongDataToAPI(songInfo, config, data);
|
||||
postSongDataToAPI(songInfo, config, data, setConfig);
|
||||
};
|
||||
|
||||
// This will store the timeout that will trigger addScrobble
|
||||
let scrobbleTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
export default builder.createMain(({ getConfig, send }) => ({
|
||||
async onLoad(_win) {
|
||||
let config = await getConfig();
|
||||
|
||||
if (!config.api_root) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
@ -1,17 +1,88 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { net } from 'electron';
|
||||
|
||||
const builder = createPluginBuilder('lumiastream', {
|
||||
import { createPlugin } from '@/utils';
|
||||
import registerCallback from '@/providers/song-info';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Lumia Stream [beta]',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
backend() {
|
||||
const secToMilisec = (t?: number) => t ? Math.round(Number(t) * 1e3) : undefined;
|
||||
const previousStatePaused = null;
|
||||
|
||||
export default builder;
|
||||
const data: LumiaData = {
|
||||
origin: 'youtubemusic',
|
||||
eventType: 'switchSong',
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
const post = (data: LumiaData) => {
|
||||
const port = 39231;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
} as const;
|
||||
const url = `http://127.0.0.1:${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}`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
import { net } from 'electron';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
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': '*',
|
||||
} as const;
|
||||
const url = `http://127.0.0.1:${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 builder.createMain(() => {
|
||||
return {
|
||||
onLoad() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -1,26 +1,41 @@
|
||||
import style from './style.css?inline';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onConfigChange, onMainLoad } from './main';
|
||||
import { onRendererLoad } from './renderer';
|
||||
|
||||
export type LyricsGeniusPluginConfig = {
|
||||
enabled: boolean;
|
||||
romanizedLyrics: boolean;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('lyrics-genius', {
|
||||
export default createPlugin({
|
||||
name: 'Lyrics Genius',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
romanizedLyrics: false,
|
||||
} as LyricsGeniusPluginConfig,
|
||||
styles: [style],
|
||||
stylesheets: [style],
|
||||
async menu({ getConfig, setConfig }) {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Romanized Lyrics',
|
||||
type: 'checkbox',
|
||||
checked: config.romanizedLyrics,
|
||||
click(item) {
|
||||
setConfig({
|
||||
romanizedLyrics: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
backend: {
|
||||
start: onMainLoad,
|
||||
onConfigChange,
|
||||
},
|
||||
renderer: onRendererLoad,
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,33 +3,31 @@ import is from 'electron-is';
|
||||
import { convert } from 'html-to-text';
|
||||
|
||||
import { GetGeniusLyric } from './types';
|
||||
import { cleanupName, type SongInfo } from '@/providers/song-info';
|
||||
|
||||
import builder from './index';
|
||||
import type { LyricsGeniusPluginConfig } from './index';
|
||||
|
||||
import { cleanupName, type SongInfo } from '../../providers/song-info';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
|
||||
let revRomanized = false;
|
||||
|
||||
export default builder.createMain(({ handle, getConfig }) =>{
|
||||
return {
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
export const onMainLoad = async ({ ipc, getConfig }: BackendContext<LyricsGeniusPluginConfig>) => {
|
||||
const config = await getConfig();
|
||||
|
||||
if (config.romanizedLyrics) {
|
||||
revRomanized = true;
|
||||
}
|
||||
if (config.romanizedLyrics) {
|
||||
revRomanized = true;
|
||||
}
|
||||
|
||||
handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => {
|
||||
const metadata = extractedSongInfo;
|
||||
return await fetchFromGenius(metadata);
|
||||
});
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
revRomanized = newConfig.romanizedLyrics;
|
||||
}
|
||||
};
|
||||
});
|
||||
ipc.handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => {
|
||||
const metadata = extractedSongInfo;
|
||||
return await fetchFromGenius(metadata);
|
||||
});
|
||||
};
|
||||
|
||||
export const onConfigChange = (newConfig: LyricsGeniusPluginConfig) => {
|
||||
revRomanized = newConfig.romanizedLyrics;
|
||||
};
|
||||
|
||||
export const fetchFromGenius = async (metadata: SongInfo) => {
|
||||
const songTitle = `${cleanupName(metadata.title)}`;
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Romanized Lyrics',
|
||||
type: 'checkbox',
|
||||
checked: config.romanizedLyrics,
|
||||
click(item) {
|
||||
setConfig({
|
||||
romanizedLyrics: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,11 +1,10 @@
|
||||
import builder from './index';
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { LyricsGeniusPluginConfig } from '@/plugins/lyrics-genius/index';
|
||||
|
||||
import type { SongInfo } from '../../providers/song-info';
|
||||
|
||||
export default builder.createRenderer(({ on, invoke }) => ({
|
||||
onLoad() {
|
||||
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
|
||||
lyricsContainer.innerHTML = `
|
||||
export const onRendererLoad = ({ ipc: { invoke, on } }: RendererContext<LyricsGeniusPluginConfig>) => {
|
||||
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>
|
||||
@ -13,97 +12,96 @@ export default builder.createRenderer(({ on, invoke }) => ({
|
||||
</yt-formatted-string>
|
||||
`;
|
||||
|
||||
if (lyrics) {
|
||||
const footer = lyricsContainer.querySelector('.footer');
|
||||
if (lyrics) {
|
||||
const footer = lyricsContainer.querySelector('.footer');
|
||||
|
||||
if (footer) {
|
||||
footer.textContent = 'Source: Genius';
|
||||
}
|
||||
if (footer) {
|
||||
footer.textContent = 'Source: Genius';
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let unregister: (() => void) | null = null;
|
||||
let unregister: (() => void) | null = null;
|
||||
|
||||
on('update-song-info', (extractedSongInfo: SongInfo) => {
|
||||
unregister?.();
|
||||
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],
|
||||
};
|
||||
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;
|
||||
// Check if disabled
|
||||
if (!tabs.lyrics?.hasAttribute('disabled')) return;
|
||||
|
||||
const lyrics = await invoke<string | null>(
|
||||
'search-genius-lyrics',
|
||||
extractedSongInfo,
|
||||
const lyrics = await 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 (window.electronIs.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 (!lyrics) {
|
||||
// Delete previous lyrics if tab is open and couldn't get new lyrics
|
||||
tabs.upNext.click();
|
||||
if (lyricsContainer) {
|
||||
callback?.();
|
||||
|
||||
return;
|
||||
setLyrics(lyricsContainer, lyrics);
|
||||
applyLyricsTabState();
|
||||
}
|
||||
|
||||
if (window.electronIs.dev()) {
|
||||
console.log('Fetched lyrics from Genius');
|
||||
};
|
||||
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 tryToInjectLyric = (callback?: () => void) => {
|
||||
const lyricsContainer = document.querySelector(
|
||||
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
|
||||
);
|
||||
const observer = new MutationObserver((_, observer) => {
|
||||
tryToInjectLyric(() => observer.disconnect());
|
||||
});
|
||||
|
||||
if (lyricsContainer) {
|
||||
callback?.();
|
||||
observer.observe(tabContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
applyLyricsTabState();
|
||||
|
||||
const observer = new MutationObserver((_, observer) => {
|
||||
tryToInjectLyric(() => observer.disconnect());
|
||||
});
|
||||
tabs.discover.addEventListener('click', applyLyricsTabState);
|
||||
tabs.lyrics.addEventListener('click', lyricsTabHandler);
|
||||
tabs.upNext.addEventListener('click', applyLyricsTabState);
|
||||
|
||||
observer.observe(tabContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
tryToInjectLyric();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}));
|
||||
unregister = () => {
|
||||
tabs.discover.removeEventListener('click', applyLyricsTabState);
|
||||
tabs.lyrics.removeEventListener('click', lyricsTabHandler);
|
||||
tabs.upNext.removeEventListener('click', applyLyricsTabState);
|
||||
};
|
||||
}, 500);
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,20 +1,24 @@
|
||||
import style from './style.css?inline';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import forwardHTML from './templates/forward.html?raw';
|
||||
import backHTML from './templates/back.html?raw';
|
||||
|
||||
const builder = createPluginBuilder('navigation', {
|
||||
export default createPlugin({
|
||||
name: 'Navigation',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
styles: [style],
|
||||
stylesheets: [style],
|
||||
renderer() {
|
||||
const forwardButton = ElementFromHtml(forwardHTML);
|
||||
const backButton = ElementFromHtml(backHTML);
|
||||
const menu = document.querySelector('#right-content');
|
||||
|
||||
if (menu) {
|
||||
menu.prepend(backButton, forwardButton);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import forwardHTML from './templates/forward.html?raw';
|
||||
import backHTML from './templates/back.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
|
||||
export default builder.createRenderer(() => {
|
||||
return {
|
||||
onLoad() {
|
||||
const forwardButton = ElementFromHtml(forwardHTML);
|
||||
const backButton = ElementFromHtml(backHTML);
|
||||
const menu = document.querySelector('#right-content');
|
||||
|
||||
if (menu) {
|
||||
menu.prepend(backButton, forwardButton);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,20 +1,46 @@
|
||||
import style from './style.css?inline';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
const builder = createPluginBuilder('no-google-login', {
|
||||
export default createPlugin({
|
||||
name: 'Remove Google Login',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
styles: [style],
|
||||
});
|
||||
stylesheets: [style],
|
||||
renderer() {
|
||||
const elementsToRemove = [
|
||||
'.sign-in-link.ytmusic-nav-bar',
|
||||
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
|
||||
];
|
||||
|
||||
export default builder;
|
||||
for (const selector of elementsToRemove) {
|
||||
const node = document.querySelector(selector);
|
||||
if (node) {
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import style from './style.css?inline';
|
||||
|
||||
import { injectCSS } from '../utils/main';
|
||||
|
||||
export default (win: BrowserWindow) => {
|
||||
injectCSS(win.webContents, style);
|
||||
};
|
||||
@ -1,39 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => ({
|
||||
onLoad() {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}));
|
||||
@ -1,36 +1,46 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
import { onConfigChange, onMainLoad } from './main';
|
||||
import { onMenu } from './menu';
|
||||
|
||||
export interface NotificationsPluginConfig {
|
||||
enabled: boolean;
|
||||
unpauseNotification: boolean;
|
||||
/**
|
||||
* Has effect only on Linux
|
||||
*/
|
||||
urgency: 'low' | 'normal' | 'critical';
|
||||
/**
|
||||
* the following has effect only on Windows
|
||||
*/
|
||||
interactive: boolean;
|
||||
/**
|
||||
* See plugins/notifications/utils for more info
|
||||
*/
|
||||
toastStyle: number;
|
||||
refreshOnPlayPause: boolean;
|
||||
trayControls: boolean;
|
||||
hideButtonText: boolean;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('notifications', {
|
||||
export const defaultConfig: NotificationsPluginConfig = {
|
||||
enabled: false,
|
||||
unpauseNotification: false,
|
||||
urgency: 'normal',
|
||||
interactive: true,
|
||||
toastStyle: 1,
|
||||
refreshOnPlayPause: false,
|
||||
trayControls: true,
|
||||
hideButtonText: false,
|
||||
};
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Notifications',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
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,
|
||||
} as NotificationsPluginConfig,
|
||||
config: defaultConfig,
|
||||
menu: onMenu,
|
||||
backend: {
|
||||
start: onMainLoad,
|
||||
onConfigChange,
|
||||
},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +1,20 @@
|
||||
import { app, BrowserWindow, Notification } from 'electron';
|
||||
|
||||
import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
|
||||
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
|
||||
import nextIcon from '@assets/media-icons-black/next.png?asset&asarUnpack';
|
||||
import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpack';
|
||||
|
||||
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
|
||||
|
||||
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 { mediaIcons } from '../../types/media-icons';
|
||||
|
||||
import playIcon from '../../../assets/media-icons-black/play.png?asset&asarUnpack';
|
||||
import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnpack';
|
||||
import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack';
|
||||
import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack';
|
||||
|
||||
import { MainPluginContext } from '../utils/builder';
|
||||
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 { mediaIcons } from '@/types/media-icons';
|
||||
|
||||
import type { NotificationsPluginConfig } from './index';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
let songControls: ReturnType<typeof getSongControls>;
|
||||
let savedNotification: Notification | undefined;
|
||||
@ -25,7 +24,7 @@ type Accessor<T> = () => T;
|
||||
export default (
|
||||
win: BrowserWindow,
|
||||
config: Accessor<NotificationsPluginConfig>,
|
||||
{ on, send }: MainPluginContext<NotificationsPluginConfig>,
|
||||
{ ipc: { on, send } }: BackendContext<NotificationsPluginConfig>,
|
||||
) => {
|
||||
const sendNotification = (songInfo: SongInfo) => {
|
||||
const iconSrc = notificationImage(songInfo, config());
|
||||
|
||||
@ -5,11 +5,12 @@ import is from 'electron-is';
|
||||
import { notificationImage } from './utils';
|
||||
import interactive from './interactive';
|
||||
|
||||
import builder, { NotificationsPluginConfig } from './index';
|
||||
import registerCallback, { type SongInfo } from '@/providers/song-info';
|
||||
|
||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
||||
import type { NotificationsPluginConfig } from './index';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
let config: NotificationsPluginConfig = builder.config;
|
||||
let config: NotificationsPluginConfig;
|
||||
|
||||
const notify = (info: SongInfo) => {
|
||||
// Send the notification
|
||||
@ -42,17 +43,14 @@ const setup = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export default builder.createMain((context) => {
|
||||
return {
|
||||
async onLoad(win) {
|
||||
config = await context.getConfig();
|
||||
export const onMainLoad = async (context: BackendContext<NotificationsPluginConfig>) => {
|
||||
config = await context.getConfig();
|
||||
|
||||
// Register the callback for new song information
|
||||
if (is.windows() && config.interactive) interactive(win, () => config, context);
|
||||
else setup();
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
};
|
||||
});
|
||||
// Register the callback for new song information
|
||||
if (is.windows() && config.interactive) interactive(context.window, () => config, context);
|
||||
else setup();
|
||||
};
|
||||
|
||||
export const onConfigChange = (newConfig: NotificationsPluginConfig) => {
|
||||
config = newConfig;
|
||||
};
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import is from 'electron-is';
|
||||
|
||||
import { MenuItem } from 'electron';
|
||||
|
||||
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
|
||||
|
||||
import builder, { NotificationsPluginConfig } from './index';
|
||||
import type { NotificationsPluginConfig } from './index';
|
||||
|
||||
import type { MenuTemplate } from '../../menu';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
export const onMenu = async ({ getConfig, setConfig }: MenuContext<NotificationsPluginConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
const getToastStyleMenuItems = (options: NotificationsPluginConfig) => {
|
||||
@ -25,7 +25,7 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
}
|
||||
|
||||
return array as Electron.MenuItemConstructorOptions[];
|
||||
}
|
||||
};
|
||||
|
||||
const getMenu = (): MenuTemplate => {
|
||||
if (is.linux()) {
|
||||
@ -92,4 +92,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
click: (item) => setConfig({ unpauseNotification: item.checked }),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
@ -3,12 +3,12 @@ import fs from 'node:fs';
|
||||
|
||||
import { app, NativeImage } from 'electron';
|
||||
|
||||
import { cache } from '../../providers/decorators';
|
||||
import { SongInfo } from '../../providers/song-info';
|
||||
import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack';
|
||||
|
||||
import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack';
|
||||
import {NotificationsPluginConfig} from "./index";
|
||||
import { cache } from '@/providers/decorators';
|
||||
import { SongInfo } from '@/providers/song-info';
|
||||
|
||||
import type { NotificationsPluginConfig } from './index';
|
||||
|
||||
const userData = app.getPath('userData');
|
||||
const temporaryIcon = path.join(userData, 'tempIcon.png');
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import style from './style.css?inline';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { onConfigChange, onMainLoad } from './main';
|
||||
import { onMenu } from './menu';
|
||||
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||
|
||||
export type PictureInPicturePluginConfig = {
|
||||
'enabled': boolean;
|
||||
@ -14,7 +17,7 @@ export type PictureInPicturePluginConfig = {
|
||||
'useNativePiP': boolean;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('picture-in-picture', {
|
||||
export default createPlugin({
|
||||
name: 'Picture In Picture',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
@ -28,13 +31,15 @@ const builder = createPluginBuilder('picture-in-picture', {
|
||||
'isInPiP': false,
|
||||
'useNativePiP': true,
|
||||
} as PictureInPicturePluginConfig,
|
||||
styles: [style],
|
||||
});
|
||||
stylesheets: [style],
|
||||
menu: onMenu,
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
backend: {
|
||||
start: onMainLoad,
|
||||
onConfigChange,
|
||||
},
|
||||
renderer: {
|
||||
start: onRendererLoad,
|
||||
onPlayerApiReady,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,22 +1,18 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { app } from 'electron';
|
||||
|
||||
import style from './style.css?inline';
|
||||
import type { PictureInPicturePluginConfig } from './index';
|
||||
|
||||
import builder, { PictureInPicturePluginConfig } from './index';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
import { injectCSS } from '../utils/main';
|
||||
let config: PictureInPicturePluginConfig;
|
||||
|
||||
export default builder.createMain(({ getConfig, setConfig, send, handle, on }) => {
|
||||
export const onMainLoad = async ({ window, getConfig, setConfig, ipc: { send, handle, on } }: BackendContext<PictureInPicturePluginConfig>) => {
|
||||
let isInPiP = false;
|
||||
let originalPosition: number[];
|
||||
let originalSize: number[];
|
||||
let originalFullScreen: boolean;
|
||||
let originalMaximized: boolean;
|
||||
|
||||
let win: BrowserWindow;
|
||||
|
||||
let config: PictureInPicturePluginConfig;
|
||||
|
||||
const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10];
|
||||
const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275];
|
||||
|
||||
@ -25,59 +21,59 @@ export default builder.createMain(({ getConfig, setConfig, send, handle, on }) =
|
||||
setConfig({ isInPiP });
|
||||
|
||||
if (isInPiP) {
|
||||
originalFullScreen = win.isFullScreen();
|
||||
originalFullScreen = window.isFullScreen();
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(false);
|
||||
window.setFullScreen(false);
|
||||
}
|
||||
|
||||
originalMaximized = win.isMaximized();
|
||||
originalMaximized = window.isMaximized();
|
||||
if (originalMaximized) {
|
||||
win.unmaximize();
|
||||
window.unmaximize();
|
||||
}
|
||||
|
||||
originalPosition = win.getPosition();
|
||||
originalSize = win.getSize();
|
||||
originalPosition = window.getPosition();
|
||||
originalSize = window.getSize();
|
||||
|
||||
handle('before-input-event', blockShortcutsInPiP);
|
||||
|
||||
win.setMaximizable(false);
|
||||
win.setFullScreenable(false);
|
||||
window.setMaximizable(false);
|
||||
window.setFullScreenable(false);
|
||||
|
||||
send('pip-toggle', true);
|
||||
|
||||
app.dock?.hide();
|
||||
win.setVisibleOnAllWorkspaces(true, {
|
||||
window.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
app.dock?.show();
|
||||
if (config.alwaysOnTop) {
|
||||
win.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
}
|
||||
} else {
|
||||
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
||||
win.setMaximizable(true);
|
||||
win.setFullScreenable(true);
|
||||
window.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
||||
window.setMaximizable(true);
|
||||
window.setFullScreenable(true);
|
||||
|
||||
send('pip-toggle', false);
|
||||
|
||||
win.setVisibleOnAllWorkspaces(false);
|
||||
win.setAlwaysOnTop(false);
|
||||
window.setVisibleOnAllWorkspaces(false);
|
||||
window.setAlwaysOnTop(false);
|
||||
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(true);
|
||||
window.setFullScreen(true);
|
||||
}
|
||||
|
||||
if (originalMaximized) {
|
||||
win.maximize();
|
||||
window.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
||||
const [w, h] = isInPiP ? pipSize() : originalSize;
|
||||
win.setPosition(x, y);
|
||||
win.setSize(w, h);
|
||||
window.setPosition(x, y);
|
||||
window.setSize(w, h);
|
||||
|
||||
win.setWindowButtonVisibility?.(!isInPiP);
|
||||
window.setWindowButtonVisibility?.(!isInPiP);
|
||||
};
|
||||
|
||||
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
||||
@ -91,30 +87,25 @@ export default builder.createMain(({ getConfig, setConfig, send, handle, on }) =
|
||||
}
|
||||
};
|
||||
|
||||
return ({
|
||||
async onLoad(window) {
|
||||
config ??= await getConfig();
|
||||
win ??= window;
|
||||
setConfig({ isInPiP });
|
||||
on('picture-in-picture', () => {
|
||||
togglePiP();
|
||||
});
|
||||
config ??= await getConfig();
|
||||
setConfig({ isInPiP });
|
||||
on('picture-in-picture', () => {
|
||||
togglePiP();
|
||||
});
|
||||
|
||||
window.on('move', () => {
|
||||
if (config.isInPiP && !config.useNativePiP) {
|
||||
setConfig({ 'pip-position': window.getPosition() as [number, number] });
|
||||
}
|
||||
});
|
||||
|
||||
window.on('resize', () => {
|
||||
if (config.isInPiP && !config.useNativePiP) {
|
||||
setConfig({ 'pip-size': window.getSize() as [number, number] });
|
||||
}
|
||||
});
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
window.on('move', () => {
|
||||
if (config.isInPiP && !config.useNativePiP) {
|
||||
setConfig({ 'pip-position': window.getPosition() as [number, number] });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.on('resize', () => {
|
||||
if (config.isInPiP && !config.useNativePiP) {
|
||||
setConfig({ 'pip-size': window.getSize() as [number, number] });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const onConfigChange = (newConfig: PictureInPicturePluginConfig) => {
|
||||
config = newConfig;
|
||||
};
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import builder from './index';
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
import type { PictureInPicturePluginConfig } from './index';
|
||||
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
|
||||
export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
||||
export const onMenu = async ({ window, getConfig, setConfig }: MenuContext<PictureInPicturePluginConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
@ -71,4 +74,4 @@ export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
@ -3,12 +3,13 @@ import keyEventAreEqual from 'keyboardevents-areequal';
|
||||
|
||||
import pipHTML from './templates/picture-in-picture.html?raw';
|
||||
|
||||
import builder, { PictureInPicturePluginConfig } from './index';
|
||||
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
import { getSongMenu } from '@/providers/dom-elements';
|
||||
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
|
||||
import type { PictureInPicturePluginConfig } from './index';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
|
||||
function $<E extends Element = Element>(selector: string) {
|
||||
return document.querySelector<E>(selector);
|
||||
}
|
||||
@ -133,42 +134,38 @@ const listenForToggle = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const onRendererLoad = async ({ getConfig }: RendererContext<PictureInPicturePluginConfig>) => {
|
||||
const config = await getConfig();
|
||||
|
||||
export default builder.createRenderer(({ getConfig }) => {
|
||||
return {
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
useNativePiP = config.useNativePiP;
|
||||
|
||||
useNativePiP = config.useNativePiP;
|
||||
|
||||
if (config.hotkey) {
|
||||
const hotkeyEvent = toKeyEvent(config.hotkey);
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (
|
||||
keyEventAreEqual(event, hotkeyEvent)
|
||||
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
|
||||
) {
|
||||
togglePictureInPicture();
|
||||
}
|
||||
});
|
||||
if (config.hotkey) {
|
||||
const hotkeyEvent = toKeyEvent(config.hotkey);
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (
|
||||
keyEventAreEqual(event, hotkeyEvent)
|
||||
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
|
||||
) {
|
||||
togglePictureInPicture();
|
||||
}
|
||||
},
|
||||
onPlayerApiReady() {
|
||||
listenForToggle();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
|
||||
await togglePictureInPicture();
|
||||
setTimeout(() => $<HTMLButtonElement>('#player')?.click());
|
||||
});
|
||||
export const onPlayerApiReady = () => {
|
||||
listenForToggle();
|
||||
|
||||
// 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onPlayerApiReady, onUnload } from './renderer';
|
||||
|
||||
const builder = createPluginBuilder('playback-speed', {
|
||||
export default createPlugin({
|
||||
name: 'Playback Speed',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
renderer: {
|
||||
stop: onUnload,
|
||||
onPlayerApiReady,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
import sliderHTML from './templates/slider.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
import { getSongMenu } from '@/providers/dom-elements';
|
||||
import { singleton } from '@/providers/decorators';
|
||||
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
import { singleton } from '../../providers/decorators';
|
||||
|
||||
|
||||
function $<E extends Element = Element>(selector: string) {
|
||||
return document.querySelector<E>(selector);
|
||||
}
|
||||
|
||||
const slider = ElementFromHtml(sliderHTML);
|
||||
|
||||
@ -21,12 +15,12 @@ const MAX_PLAYBACK_SPEED = 16;
|
||||
let playbackSpeed = 1;
|
||||
|
||||
const updatePlayBackSpeed = () => {
|
||||
const videoElement = $<HTMLVideoElement>('video');
|
||||
const videoElement = document.querySelector<HTMLVideoElement>('video');
|
||||
if (videoElement) {
|
||||
videoElement.playbackRate = playbackSpeed;
|
||||
}
|
||||
|
||||
const playbackSpeedElement = $('#playback-speed-value');
|
||||
const playbackSpeedElement = document.querySelector('#playback-speed-value');
|
||||
if (playbackSpeedElement) {
|
||||
playbackSpeedElement.innerHTML = String(playbackSpeed);
|
||||
}
|
||||
@ -44,7 +38,7 @@ const immediateValueChangedListener = (e: Event) => {
|
||||
};
|
||||
|
||||
const setupSliderListener = singleton(() => {
|
||||
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener);
|
||||
document.querySelector('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener);
|
||||
});
|
||||
|
||||
const observePopupContainer = () => {
|
||||
@ -64,7 +58,7 @@ const observePopupContainer = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const popupContainer = $('ytmusic-popup-container');
|
||||
const popupContainer = document.querySelector('ytmusic-popup-container');
|
||||
if (popupContainer) {
|
||||
observer.observe(popupContainer, {
|
||||
childList: true,
|
||||
@ -74,7 +68,7 @@ const observePopupContainer = () => {
|
||||
};
|
||||
|
||||
const observeVideo = () => {
|
||||
const video = $<HTMLVideoElement>('video');
|
||||
const video = document.querySelector<HTMLVideoElement>('video');
|
||||
if (video) {
|
||||
video.addEventListener('ratechange', forcePlaybackRate);
|
||||
video.addEventListener('srcChanged', forcePlaybackRate);
|
||||
@ -95,7 +89,7 @@ const wheelEventListener = (e: WheelEvent) => {
|
||||
|
||||
updatePlayBackSpeed();
|
||||
// Update slider position
|
||||
const playbackSpeedSilder = $<HTMLElement & { value: number }>('#playback-speed-slider');
|
||||
const playbackSpeedSilder = document.querySelector<HTMLElement & { value: number }>('#playback-speed-slider');
|
||||
if (playbackSpeedSilder) {
|
||||
playbackSpeedSilder.value = playbackSpeed;
|
||||
}
|
||||
@ -114,22 +108,19 @@ function forcePlaybackRate(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
export default builder.createRenderer(() => {
|
||||
return {
|
||||
onPlayerApiReady() {
|
||||
observePopupContainer();
|
||||
observeVideo();
|
||||
setupWheelListener();
|
||||
},
|
||||
onUnload() {
|
||||
const video = $<HTMLVideoElement>('video');
|
||||
if (video) {
|
||||
video.removeEventListener('ratechange', forcePlaybackRate);
|
||||
video.removeEventListener('srcChanged', forcePlaybackRate);
|
||||
}
|
||||
slider.removeEventListener('wheel', wheelEventListener);
|
||||
getSongMenu()?.removeChild(slider);
|
||||
$('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener);
|
||||
}
|
||||
};
|
||||
});
|
||||
export const onPlayerApiReady = () => {
|
||||
observePopupContainer();
|
||||
observeVideo();
|
||||
setupWheelListener();
|
||||
};
|
||||
|
||||
export const onUnload = () => {
|
||||
const video = document.querySelector<HTMLVideoElement>('video');
|
||||
if (video) {
|
||||
video.removeEventListener('ratechange', forcePlaybackRate);
|
||||
video.removeEventListener('srcChanged', forcePlaybackRate);
|
||||
}
|
||||
slider.removeEventListener('wheel', wheelEventListener);
|
||||
getSongMenu()?.removeChild(slider);
|
||||
document.querySelector('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener);
|
||||
};
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import hudStyle from './volume-hud.css?inline';
|
||||
import { globalShortcut, MenuItem } from 'electron';
|
||||
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import hudStyle from './volume-hud.css?inline';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
import { overrideListener } from './override';
|
||||
import { onConfigChange, onPlayerApiReady } from './renderer';
|
||||
|
||||
export type PreciseVolumePluginConfig = {
|
||||
enabled: boolean;
|
||||
@ -13,7 +19,7 @@ export type PreciseVolumePluginConfig = {
|
||||
savedVolume: number | undefined;
|
||||
};
|
||||
|
||||
const builder = createPluginBuilder('precise-volume', {
|
||||
export default createPlugin({
|
||||
name: 'Precise Volume',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
@ -26,13 +32,107 @@ const builder = createPluginBuilder('precise-volume', {
|
||||
},
|
||||
savedVolume: undefined, // Plugin save volume between session here
|
||||
} as PreciseVolumePluginConfig,
|
||||
styles: [hudStyle],
|
||||
});
|
||||
stylesheets: [hudStyle],
|
||||
menu: async ({ setConfig, getConfig, window }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
export default builder;
|
||||
function changeOptions(changedOptions: Partial<PreciseVolumePluginConfig>, options: PreciseVolumePluginConfig) {
|
||||
for (const option in changedOptions) {
|
||||
// HACK: Weird TypeScript error
|
||||
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
setConfig(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(options: PreciseVolumePluginConfig) {
|
||||
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(),
|
||||
}, window);
|
||||
|
||||
if (output || output === 0) { // 0 is somewhat valid
|
||||
changeOptions({ steps: output }, options);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptGlobalShortcuts(options: PreciseVolumePluginConfig, 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(),
|
||||
}, window);
|
||||
|
||||
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);
|
||||
|
||||
item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown);
|
||||
} else {
|
||||
// Reset checkbox if prompt was canceled
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Local Arrowkeys Controls',
|
||||
type: 'checkbox',
|
||||
checked: Boolean(config.arrowsShortcut),
|
||||
click(item) {
|
||||
changeOptions({ arrowsShortcut: item.checked }, config);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Global Hotkeys',
|
||||
type: 'checkbox',
|
||||
checked: Boolean(config.globalShortcuts?.volumeUp ?? config.globalShortcuts?.volumeDown),
|
||||
click: (item) => promptGlobalShortcuts(config, item),
|
||||
},
|
||||
{
|
||||
label: 'Set Custom Volume Steps',
|
||||
click: () => promptVolumeSteps(config),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
async backend({ getConfig, ipc }) {
|
||||
const config = await getConfig();
|
||||
|
||||
if (config.globalShortcuts?.volumeUp) {
|
||||
globalShortcut.register(config.globalShortcuts.volumeUp, () => ipc.send('changeVolume', true));
|
||||
}
|
||||
|
||||
if (config.globalShortcuts?.volumeDown) {
|
||||
globalShortcut.register(config.globalShortcuts.volumeDown, () => ipc.send('changeVolume', false));
|
||||
}
|
||||
},
|
||||
|
||||
renderer: {
|
||||
start() {
|
||||
overrideListener();
|
||||
},
|
||||
onPlayerApiReady,
|
||||
onConfigChange,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import { globalShortcut } from 'electron';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createMain(({ getConfig, send }) => ({
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
|
||||
if (config.globalShortcuts?.volumeUp) {
|
||||
globalShortcut.register(config.globalShortcuts.volumeUp, () => send('changeVolume', true));
|
||||
}
|
||||
|
||||
if (config.globalShortcuts?.volumeDown) {
|
||||
globalShortcut.register(config.globalShortcuts.volumeDown, () => send('changeVolume', false));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@ -1,90 +0,0 @@
|
||||
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow, MenuItem } from 'electron';
|
||||
|
||||
import builder, { PreciseVolumePluginConfig } from './index';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
|
||||
export default builder.createMenu(async ({ setConfig, getConfig, window }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
function changeOptions(changedOptions: Partial<PreciseVolumePluginConfig>, options: PreciseVolumePluginConfig, win: BrowserWindow) {
|
||||
for (const option in changedOptions) {
|
||||
// HACK: Weird TypeScript error
|
||||
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
|
||||
}
|
||||
|
||||
setConfig(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: PreciseVolumePluginConfig) {
|
||||
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: PreciseVolumePluginConfig, 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;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Local Arrowkeys Controls',
|
||||
type: 'checkbox',
|
||||
checked: Boolean(config.arrowsShortcut),
|
||||
click(item) {
|
||||
changeOptions({ arrowsShortcut: item.checked }, config, window);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Global Hotkeys',
|
||||
type: 'checkbox',
|
||||
checked: Boolean(config.globalShortcuts?.volumeUp ?? config.globalShortcuts?.volumeDown),
|
||||
click: (item) => promptGlobalShortcuts(window, config, item),
|
||||
},
|
||||
{
|
||||
label: 'Set Custom Volume Steps',
|
||||
click: () => promptVolumeSteps(window, config),
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,5 +1,4 @@
|
||||
/* what */
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
|
||||
const ignored = {
|
||||
id: ['volume-slider', 'expand-volume-slider'],
|
||||
@ -9,7 +8,8 @@ const ignored = {
|
||||
function overrideAddEventListener() {
|
||||
// YO WHAT ARE YOU DOING NOW?!?!
|
||||
// Save native addEventListener
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error - We know what we're doing
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
Element.prototype._addEventListener = Element.prototype.addEventListener;
|
||||
// Override addEventListener to Ignore specific events in volume-slider
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { overrideListener } from './override';
|
||||
import { type PreciseVolumePluginConfig } from './index';
|
||||
|
||||
import builder, { type PreciseVolumePluginConfig } from './index';
|
||||
import { debounce } from '@/providers/decorators';
|
||||
|
||||
import { debounce } from '../../providers/decorators';
|
||||
|
||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||
|
||||
function $<E extends Element = Element>(selector: string) {
|
||||
return document.querySelector<E>(selector);
|
||||
@ -23,12 +22,14 @@ export const moveVolumeHud = debounce((showVideo: boolean) => {
|
||||
: '0';
|
||||
}, 250);
|
||||
|
||||
export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
let options: PreciseVolumePluginConfig = await getConfig();
|
||||
let options: PreciseVolumePluginConfig;
|
||||
|
||||
export const onPlayerApiReady = (playerApi: YoutubePlayer, context: RendererContext<PreciseVolumePluginConfig>) => {
|
||||
api = playerApi;
|
||||
|
||||
// Without this function it would rewrite config 20 time when volume change by 20
|
||||
const writeOptions = debounce(() => {
|
||||
setConfig(options);
|
||||
context.setConfig(options);
|
||||
}, 1000);
|
||||
|
||||
const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
|
||||
@ -254,20 +255,12 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
}
|
||||
}
|
||||
|
||||
context.ipc.on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease));
|
||||
context.ipc.on('setVolume', (value: number) => setVolume(value));
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
overrideListener();
|
||||
},
|
||||
onPlayerApiReady(playerApi) {
|
||||
api = playerApi;
|
||||
firstRun();
|
||||
};
|
||||
|
||||
on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease));
|
||||
on('setVolume', (value: number) => setVolume(value));
|
||||
firstRun();
|
||||
},
|
||||
onConfigChange(config) {
|
||||
options = config;
|
||||
}
|
||||
};
|
||||
});
|
||||
export const onConfigChange = (config: PreciseVolumePluginConfig) => {
|
||||
options = config;
|
||||
};
|
||||
|
||||
@ -1,17 +1,65 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { dialog } from 'electron';
|
||||
|
||||
const builder = createPluginBuilder('quality-changer', {
|
||||
import QualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
|
||||
|
||||
import { createPlugin } from '@/utils';
|
||||
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||
|
||||
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Video Quality Changer',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
backend({ ipc, window }) {
|
||||
ipc.handle('qualityChanger', async (qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(window, {
|
||||
type: 'question',
|
||||
buttons: qualityLabels,
|
||||
defaultId: currentIndex,
|
||||
title: 'Choose Video Quality',
|
||||
message: 'Choose Video Quality:',
|
||||
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
|
||||
cancelId: -1,
|
||||
}));
|
||||
},
|
||||
|
||||
renderer: {
|
||||
qualitySettingsButton: ElementFromHtml(QualitySettingsTemplate),
|
||||
onPlayerApiReady(api: YoutubePlayer, context) {
|
||||
const getPlayer = () => document.querySelector<HTMLVideoElement>('#player');
|
||||
const chooseQuality = () => {
|
||||
setTimeout(() => getPlayer()?.click());
|
||||
|
||||
const qualityLevels = api.getAvailableQualityLevels();
|
||||
|
||||
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
|
||||
|
||||
(context.ipc.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex) as Promise<{ response: number }>)
|
||||
.then((promise) => {
|
||||
if (promise.response === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuality = qualityLevels[promise.response];
|
||||
api.setPlaybackQualityRange(newQuality);
|
||||
api.setPlaybackQuality(newQuality);
|
||||
});
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
document.querySelector('.top-row-buttons.ytmusic-player')?.prepend(this.qualitySettingsButton);
|
||||
|
||||
this.qualitySettingsButton.addEventListener('click', chooseQuality);
|
||||
};
|
||||
|
||||
setup();
|
||||
},
|
||||
stop() {
|
||||
document.querySelector('.top-row-buttons.ytmusic-player')?.removeChild(this.qualitySettingsButton);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import { dialog, BrowserWindow } from 'electron';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createMain(({ handle }) => ({
|
||||
onLoad(win: BrowserWindow) {
|
||||
handle('qualityChanger', async (qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(win, {
|
||||
type: 'question',
|
||||
buttons: qualityLabels,
|
||||
defaultId: currentIndex,
|
||||
title: 'Choose Video Quality',
|
||||
message: 'Choose Video Quality:',
|
||||
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
|
||||
cancelId: -1,
|
||||
}));
|
||||
}
|
||||
}));
|
||||
@ -1,54 +0,0 @@
|
||||
import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
|
||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
||||
|
||||
export default builder.createRenderer(({ invoke }) => {
|
||||
function $(selector: string): HTMLElement | null {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate);
|
||||
|
||||
let api: YoutubePlayer;
|
||||
|
||||
const chooseQuality = () => {
|
||||
setTimeout(() => $('#player')?.click());
|
||||
|
||||
const qualityLevels = api.getAvailableQualityLevels();
|
||||
|
||||
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
|
||||
|
||||
invoke<{ response: number }>('qualityChanger', api.getAvailableQualityLabels(), currentIndex)
|
||||
.then((promise) => {
|
||||
if (promise.response === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuality = qualityLevels[promise.response];
|
||||
api.setPlaybackQualityRange(newQuality);
|
||||
api.setPlaybackQuality(newQuality);
|
||||
});
|
||||
};
|
||||
|
||||
function setup() {
|
||||
$('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton);
|
||||
|
||||
qualitySettingsButton.addEventListener('click', chooseQuality);
|
||||
}
|
||||
|
||||
return {
|
||||
onPlayerApiReady(playerApi) {
|
||||
api = playerApi;
|
||||
|
||||
setup();
|
||||
},
|
||||
onUnload() {
|
||||
$('.top-row-buttons.ytmusic-player')?.removeChild(qualitySettingsButton);
|
||||
qualitySettingsButton.removeEventListener('click', chooseQuality);
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,4 +1,6 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onMainLoad } from './main';
|
||||
import { onMenu } from './menu';
|
||||
|
||||
export type ShortcutMappingType = {
|
||||
previous: string;
|
||||
@ -12,7 +14,7 @@ export type ShortcutsPluginConfig = {
|
||||
local: ShortcutMappingType;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('shortcuts', {
|
||||
export default createPlugin({
|
||||
name: 'Shortcuts (& MPRIS)',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
@ -29,12 +31,7 @@ const builder = createPluginBuilder('shortcuts', {
|
||||
next: '',
|
||||
},
|
||||
} as ShortcutsPluginConfig,
|
||||
menu: onMenu,
|
||||
|
||||
backend: onMainLoad,
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { BrowserWindow, globalShortcut } from 'electron';
|
||||
import is from 'electron-is';
|
||||
import electronLocalshortcut from 'electron-localshortcut';
|
||||
import { register as registerElectronLocalShortcut } from 'electron-localshortcut';
|
||||
|
||||
import registerMPRIS from './mpris';
|
||||
import getSongControls from '@/providers/song-controls';
|
||||
|
||||
import builder, { ShortcutMappingType } from './index';
|
||||
import type { ShortcutMappingType, ShortcutsPluginConfig } from './index';
|
||||
|
||||
import getSongControls from '../../providers/song-controls';
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
|
||||
function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
||||
@ -16,62 +17,58 @@ function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: st
|
||||
}
|
||||
|
||||
function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
||||
electronLocalshortcut.register(win, shortcut, () => {
|
||||
registerElectronLocalShortcut(win, shortcut, () => {
|
||||
action(win.webContents);
|
||||
});
|
||||
}
|
||||
|
||||
export default builder.createMain(({ getConfig }) => {
|
||||
return {
|
||||
async onLoad(win) {
|
||||
const config = await getConfig();
|
||||
export const onMainLoad = async ({ getConfig, window }: BackendContext<ShortcutsPluginConfig>) => {
|
||||
const config = await getConfig();
|
||||
|
||||
const songControls = getSongControls(win);
|
||||
const { playPause, next, previous, search } = songControls;
|
||||
const songControls = getSongControls(window);
|
||||
const { playPause, next, previous, search } = songControls;
|
||||
|
||||
if (config.overrideMediaKeys) {
|
||||
_registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause);
|
||||
_registerGlobalShortcut(win.webContents, 'MediaNextTrack', next);
|
||||
_registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous);
|
||||
if (config.overrideMediaKeys) {
|
||||
_registerGlobalShortcut(window.webContents, 'MediaPlayPause', playPause);
|
||||
_registerGlobalShortcut(window.webContents, 'MediaNextTrack', next);
|
||||
_registerGlobalShortcut(window.webContents, 'MediaPreviousTrack', previous);
|
||||
}
|
||||
|
||||
_registerLocalShortcut(window, 'CommandOrControl+F', search);
|
||||
_registerLocalShortcut(window, 'CommandOrControl+L', search);
|
||||
|
||||
if (is.linux()) {
|
||||
registerMPRIS(window);
|
||||
}
|
||||
|
||||
const { global, local } = config;
|
||||
const shortcutOptions = { global, local };
|
||||
|
||||
for (const optionType in shortcutOptions) {
|
||||
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
|
||||
}
|
||||
|
||||
function registerAllShortcuts(container: ShortcutMappingType, type: string) {
|
||||
for (const _action in container) {
|
||||
// HACK: _action is detected as string, but it's actually a key of ShortcutMappingType
|
||||
const action = _action as keyof ShortcutMappingType;
|
||||
|
||||
if (!container[action]) {
|
||||
continue; // Action accelerator is empty
|
||||
}
|
||||
|
||||
_registerLocalShortcut(win, 'CommandOrControl+F', search);
|
||||
_registerLocalShortcut(win, 'CommandOrControl+L', search);
|
||||
|
||||
if (is.linux()) {
|
||||
registerMPRIS(win);
|
||||
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
|
||||
const actionCallback: () => void = songControls[action];
|
||||
if (typeof actionCallback !== 'function') {
|
||||
console.warn('Invalid action', action);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { global, local } = config;
|
||||
const shortcutOptions = { global, local };
|
||||
|
||||
for (const optionType in shortcutOptions) {
|
||||
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
|
||||
}
|
||||
|
||||
function registerAllShortcuts(container: ShortcutMappingType, type: string) {
|
||||
for (const _action in container) {
|
||||
// HACK: _action is detected as string, but it's actually a key of ShortcutMappingType
|
||||
const action = _action as keyof ShortcutMappingType;
|
||||
|
||||
if (!container[action]) {
|
||||
continue; // Action accelerator is empty
|
||||
}
|
||||
|
||||
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
|
||||
const actionCallback: () => void = songControls[action];
|
||||
if (typeof actionCallback !== 'function') {
|
||||
console.warn('Invalid action', action);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'global') {
|
||||
_registerGlobalShortcut(win.webContents, container[action], actionCallback);
|
||||
} else { // Type === "local"
|
||||
_registerLocalShortcut(win, local[action], actionCallback);
|
||||
}
|
||||
}
|
||||
if (type === 'global') {
|
||||
_registerGlobalShortcut(window.webContents, container[action], actionCallback);
|
||||
} else { // Type === "local"
|
||||
_registerLocalShortcut(window, local[action], actionCallback);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
||||
|
||||
import builder, { ShortcutsPluginConfig } from './index';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
|
||||
import type { ShortcutsPluginConfig } from './index';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
||||
export const onMenu = async ({ window, getConfig, setConfig }: MenuContext<ShortcutsPluginConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
/**
|
||||
@ -52,4 +53,4 @@ export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
||||
click: (item) => setConfig({ overrideMediaKeys: item.checked }),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,9 +2,9 @@ import { BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import mpris, { Track } from '@jellybrick/mpris-service';
|
||||
|
||||
import registerCallback from '../../providers/song-info';
|
||||
import getSongControls from '../../providers/song-controls';
|
||||
import config from '../../config';
|
||||
import registerCallback from '@/providers/song-info';
|
||||
import getSongControls from '@/providers/song-controls';
|
||||
import config from '@/config';
|
||||
|
||||
function setupMPRIS() {
|
||||
const instance = new mpris({
|
||||
|
||||
@ -1,23 +1,20 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onRendererLoad, onRendererUnload } from './renderer';
|
||||
|
||||
export type SkipSilencesPluginConfig = {
|
||||
enabled: boolean;
|
||||
onlySkipBeginning: boolean;
|
||||
};
|
||||
|
||||
const builder = createPluginBuilder('skip-silences', {
|
||||
export default createPlugin({
|
||||
name: 'Skip Silences',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
onlySkipBeginning: false,
|
||||
} as SkipSilencesPluginConfig,
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
renderer: {
|
||||
start: onRendererLoad,
|
||||
stop: onRendererUnload,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,140 +1,138 @@
|
||||
import builder, { type SkipSilencesPluginConfig } from './index';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { SkipSilencesPluginConfig } from './index';
|
||||
|
||||
export default builder.createRenderer(({ getConfig }) => {
|
||||
let config: SkipSilencesPluginConfig;
|
||||
let config: SkipSilencesPluginConfig;
|
||||
|
||||
let isSilent = false;
|
||||
let hasAudioStarted = false;
|
||||
let isSilent = false;
|
||||
let hasAudioStarted = false;
|
||||
|
||||
const smoothing = 0.1;
|
||||
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
|
||||
const interval = 2; // Ms
|
||||
const history = 10;
|
||||
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
|
||||
const smoothing = 0.1;
|
||||
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
|
||||
const interval = 2; // Ms
|
||||
const history = 10;
|
||||
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
|
||||
|
||||
let playOrSeekHandler: (() => void) | undefined;
|
||||
let playOrSeekHandler: (() => void) | undefined;
|
||||
|
||||
const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => {
|
||||
let maxVolume = Number.NEGATIVE_INFINITY;
|
||||
analyser.getFloatFrequencyData(fftBins);
|
||||
const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => {
|
||||
let maxVolume = Number.NEGATIVE_INFINITY;
|
||||
analyser.getFloatFrequencyData(fftBins);
|
||||
|
||||
for (let i = 4, ii = fftBins.length; i < ii; i++) {
|
||||
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
|
||||
maxVolume = fftBins[i];
|
||||
}
|
||||
for (let i = 4, ii = fftBins.length; i < ii; i++) {
|
||||
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
|
||||
maxVolume = fftBins[i];
|
||||
}
|
||||
}
|
||||
|
||||
return maxVolume;
|
||||
};
|
||||
return maxVolume;
|
||||
};
|
||||
|
||||
const audioCanPlayListener = (e: CustomEvent<Compressor>) => {
|
||||
const video = document.querySelector('video');
|
||||
const { audioContext } = e.detail;
|
||||
const sourceNode = e.detail.audioSource;
|
||||
const audioCanPlayListener = (e: CustomEvent<Compressor>) => {
|
||||
const video = document.querySelector('video');
|
||||
const { audioContext } = e.detail;
|
||||
const sourceNode = e.detail.audioSource;
|
||||
|
||||
// Use an audio analyser similar to Hark
|
||||
// https://github.com/otalk/hark/blob/master/hark.bundle.js
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = smoothing;
|
||||
const fftBins = new Float32Array(analyser.frequencyBinCount);
|
||||
// Use an audio analyser similar to Hark
|
||||
// https://github.com/otalk/hark/blob/master/hark.bundle.js
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = smoothing;
|
||||
const fftBins = new Float32Array(analyser.frequencyBinCount);
|
||||
|
||||
sourceNode.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
sourceNode.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
|
||||
const looper = () => {
|
||||
setTimeout(() => {
|
||||
const currentVolume = getMaxVolume(analyser, fftBins);
|
||||
const looper = () => {
|
||||
setTimeout(() => {
|
||||
const currentVolume = getMaxVolume(analyser, fftBins);
|
||||
|
||||
let history = 0;
|
||||
if (currentVolume > threshold && isSilent) {
|
||||
// Trigger quickly, short history
|
||||
for (
|
||||
let i = speakingHistory.length - 3;
|
||||
i < speakingHistory.length;
|
||||
i++
|
||||
) {
|
||||
history += speakingHistory[i];
|
||||
}
|
||||
|
||||
if (history >= 2) {
|
||||
// Not silent
|
||||
isSilent = false;
|
||||
hasAudioStarted = true;
|
||||
}
|
||||
} else if (currentVolume < threshold && !isSilent) {
|
||||
for (const element of speakingHistory) {
|
||||
history += element;
|
||||
}
|
||||
|
||||
if (history == 0 // Silent
|
||||
|
||||
&& !(
|
||||
video && (
|
||||
video.paused
|
||||
|| video.seeking
|
||||
|| video.ended
|
||||
|| video.muted
|
||||
|| video.volume === 0
|
||||
)
|
||||
)
|
||||
) {
|
||||
isSilent = true;
|
||||
skipSilence();
|
||||
}
|
||||
let history = 0;
|
||||
if (currentVolume > threshold && isSilent) {
|
||||
// Trigger quickly, short history
|
||||
for (
|
||||
let i = speakingHistory.length - 3;
|
||||
i < speakingHistory.length;
|
||||
i++
|
||||
) {
|
||||
history += speakingHistory[i];
|
||||
}
|
||||
|
||||
speakingHistory.shift();
|
||||
speakingHistory.push(Number(currentVolume > threshold));
|
||||
if (history >= 2) {
|
||||
// Not silent
|
||||
isSilent = false;
|
||||
hasAudioStarted = true;
|
||||
}
|
||||
} else if (currentVolume < threshold && !isSilent) {
|
||||
for (const element of speakingHistory) {
|
||||
history += element;
|
||||
}
|
||||
|
||||
looper();
|
||||
}, interval);
|
||||
};
|
||||
if (history == 0 // Silent
|
||||
|
||||
looper();
|
||||
|
||||
const skipSilence = () => {
|
||||
if (config.onlySkipBeginning && hasAudioStarted) {
|
||||
return;
|
||||
&& !(
|
||||
video && (
|
||||
video.paused
|
||||
|| video.seeking
|
||||
|| video.ended
|
||||
|| video.muted
|
||||
|| video.volume === 0
|
||||
)
|
||||
)
|
||||
) {
|
||||
isSilent = true;
|
||||
skipSilence();
|
||||
}
|
||||
}
|
||||
|
||||
if (isSilent && video && !video.paused) {
|
||||
video.currentTime += 0.2; // In s
|
||||
}
|
||||
};
|
||||
speakingHistory.shift();
|
||||
speakingHistory.push(Number(currentVolume > threshold));
|
||||
|
||||
playOrSeekHandler = () => {
|
||||
hasAudioStarted = false;
|
||||
skipSilence();
|
||||
};
|
||||
|
||||
video?.addEventListener('play', playOrSeekHandler);
|
||||
video?.addEventListener('seeked', playOrSeekHandler);
|
||||
looper();
|
||||
}, interval);
|
||||
};
|
||||
|
||||
return {
|
||||
async onLoad() {
|
||||
config = await getConfig();
|
||||
looper();
|
||||
|
||||
document.addEventListener(
|
||||
'audioCanPlay',
|
||||
audioCanPlayListener,
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
onUnload() {
|
||||
document.removeEventListener(
|
||||
'audioCanPlay',
|
||||
audioCanPlayListener,
|
||||
);
|
||||
const skipSilence = () => {
|
||||
if (config.onlySkipBeginning && hasAudioStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playOrSeekHandler) {
|
||||
const video = document.querySelector('video');
|
||||
video?.removeEventListener('play', playOrSeekHandler);
|
||||
video?.removeEventListener('seeked', playOrSeekHandler);
|
||||
}
|
||||
if (isSilent && video && !video.paused) {
|
||||
video.currentTime += 0.2; // In s
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
playOrSeekHandler = () => {
|
||||
hasAudioStarted = false;
|
||||
skipSilence();
|
||||
};
|
||||
|
||||
video?.addEventListener('play', playOrSeekHandler);
|
||||
video?.addEventListener('seeked', playOrSeekHandler);
|
||||
};
|
||||
|
||||
export const onRendererLoad = async ({ getConfig }: RendererContext<SkipSilencesPluginConfig>) => {
|
||||
config = await getConfig();
|
||||
|
||||
document.addEventListener(
|
||||
'audioCanPlay',
|
||||
audioCanPlayListener,
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const onRendererUnload = () => {
|
||||
document.removeEventListener(
|
||||
'audioCanPlay',
|
||||
audioCanPlayListener,
|
||||
);
|
||||
|
||||
if (playOrSeekHandler) {
|
||||
const video = document.querySelector('video');
|
||||
video?.removeEventListener('play', playOrSeekHandler);
|
||||
video?.removeEventListener('seeked', playOrSeekHandler);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import is from 'electron-is';
|
||||
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
import { sortSegments } from './segments';
|
||||
|
||||
import type { GetPlayerResponse } from '@/types/get-player-response';
|
||||
import type { Segment, SkipSegment } from './types';
|
||||
|
||||
export type SponsorBlockPluginConfig = {
|
||||
enabled: boolean;
|
||||
@ -6,7 +13,9 @@ export type SponsorBlockPluginConfig = {
|
||||
categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[];
|
||||
};
|
||||
|
||||
const builder = createPluginBuilder('sponsorblock', {
|
||||
let currentSegments: Segment[] = [];
|
||||
|
||||
export default createPlugin({
|
||||
name: 'SponsorBlock',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
@ -21,12 +30,83 @@ const builder = createPluginBuilder('sponsorblock', {
|
||||
'music_offtopic',
|
||||
],
|
||||
} as SponsorBlockPluginConfig,
|
||||
});
|
||||
async backend({ getConfig, ipc }) {
|
||||
const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
|
||||
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
|
||||
categories,
|
||||
)}`;
|
||||
try {
|
||||
const resp = await fetch(sponsorBlockURL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
return [];
|
||||
}
|
||||
|
||||
export default builder;
|
||||
const segments = await resp.json() as SkipSegment[];
|
||||
return sortSegments(
|
||||
segments.map((submission) => submission.segment),
|
||||
);
|
||||
} catch (error) {
|
||||
if (is.dev()) {
|
||||
console.log('error on sponsorblock request:', error);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
const { apiURL, categories } = config;
|
||||
|
||||
ipc.on('video-src-changed', async (data: GetPlayerResponse) => {
|
||||
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
|
||||
ipc.send('sponsorblock-skip', segments);
|
||||
});
|
||||
},
|
||||
renderer: {
|
||||
timeUpdateListener: (e: Event) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
const target = e.target;
|
||||
|
||||
for (const segment of currentSegments) {
|
||||
if (
|
||||
target.currentTime >= segment[0]
|
||||
&& target.currentTime < segment[1]
|
||||
) {
|
||||
target.currentTime = segment[1];
|
||||
if (window.electronIs.dev()) {
|
||||
console.log('SponsorBlock: skipping segment', segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
resetSegments: () => currentSegments = [],
|
||||
start({ ipc }) {
|
||||
ipc.on('sponsorblock-skip', (segments: Segment[]) => {
|
||||
currentSegments = segments;
|
||||
});
|
||||
},
|
||||
onPlayerApiReady() {
|
||||
const video = document.querySelector<HTMLVideoElement>('video');
|
||||
if (!video) return;
|
||||
|
||||
video.addEventListener('timeupdate', this.timeUpdateListener);
|
||||
// Reset segments on song end
|
||||
video.addEventListener('emptied', this.resetSegments);
|
||||
},
|
||||
stop() {
|
||||
const video = document.querySelector<HTMLVideoElement>('video');
|
||||
if (!video) return;
|
||||
|
||||
video.removeEventListener('timeupdate', this.timeUpdateListener);
|
||||
video.removeEventListener('emptied', this.resetSegments);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
import is from 'electron-is';
|
||||
|
||||
import { sortSegments } from './segments';
|
||||
|
||||
import { SkipSegment } from './types';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import type { GetPlayerResponse } from '../../types/get-player-response';
|
||||
|
||||
const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
|
||||
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
|
||||
categories,
|
||||
)}`;
|
||||
try {
|
||||
const resp = await fetch(sponsorBlockURL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments = await resp.json() as SkipSegment[];
|
||||
return sortSegments(
|
||||
segments.map((submission) => submission.segment),
|
||||
);
|
||||
} catch (error) {
|
||||
if (is.dev()) {
|
||||
console.log('error on sponsorblock request:', error);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export default builder.createMain(({ getConfig, on, send }) => ({
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
|
||||
const { apiURL, categories } = config;
|
||||
|
||||
on('video-src-changed', async (_, data: GetPlayerResponse) => {
|
||||
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
|
||||
send('sponsorblock-skip', segments);
|
||||
});
|
||||
}
|
||||
}));
|
||||
@ -1,49 +0,0 @@
|
||||
import { Segment } from './types';
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(({ on }) => {
|
||||
let currentSegments: Segment[] = [];
|
||||
|
||||
const timeUpdateListener = (e: Event) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
const target = e.target;
|
||||
|
||||
for (const segment of currentSegments) {
|
||||
if (
|
||||
target.currentTime >= segment[0]
|
||||
&& target.currentTime < segment[1]
|
||||
) {
|
||||
target.currentTime = segment[1];
|
||||
if (window.electronIs.dev()) {
|
||||
console.log('SponsorBlock: skipping segment', segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetSegments = () => currentSegments = [];
|
||||
|
||||
return ({
|
||||
onLoad() {
|
||||
on('sponsorblock-skip', (_, segments: Segment[]) => {
|
||||
currentSegments = segments;
|
||||
});
|
||||
},
|
||||
onPlayerApiReady() {
|
||||
const video = document.querySelector<HTMLVideoElement>('video');
|
||||
if (!video) return;
|
||||
|
||||
video.addEventListener('timeupdate', timeUpdateListener);
|
||||
// Reset segments on song end
|
||||
video.addEventListener('emptied', resetSegments);
|
||||
},
|
||||
onUnload() {
|
||||
const video = document.querySelector<HTMLVideoElement>('video');
|
||||
if (!video) return;
|
||||
|
||||
video.removeEventListener('timeupdate', timeUpdateListener);
|
||||
video.removeEventListener('emptied', resetSegments);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,17 +1,84 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
|
||||
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
|
||||
import nextIcon from '@assets/media-icons-black/next.png?asset&asarUnpack';
|
||||
import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpack';
|
||||
|
||||
const builder = createPluginBuilder('taskbar-mediacontrol', {
|
||||
import { nativeImage } from 'electron';
|
||||
|
||||
import { createPlugin } from '@/utils';
|
||||
import getSongControls from '@/providers/song-controls';
|
||||
import registerCallback, { type SongInfo } from '@/providers/song-info';
|
||||
import { mediaIcons } from '@/types/media-icons';
|
||||
|
||||
export default createPlugin({
|
||||
name: 'Taskbar Media Control',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
backend({ window }) {
|
||||
let currentSongInfo: SongInfo;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
const { playPause, next, previous } = getSongControls(window);
|
||||
|
||||
const setThumbar = (songInfo: SongInfo) => {
|
||||
// Wait for song to start before setting thumbar
|
||||
if (!songInfo?.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Win32 require full rewrite of components
|
||||
window.setThumbarButtons([
|
||||
{
|
||||
tooltip: 'Previous',
|
||||
icon: nativeImage.createFromPath(get('previous')),
|
||||
click() {
|
||||
previous();
|
||||
},
|
||||
}, {
|
||||
tooltip: 'Play/Pause',
|
||||
// Update icon based on play state
|
||||
icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
|
||||
click() {
|
||||
playPause();
|
||||
},
|
||||
}, {
|
||||
tooltip: 'Next',
|
||||
icon: nativeImage.createFromPath(get('next')),
|
||||
click() {
|
||||
next();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Util
|
||||
const get = (kind: keyof typeof mediaIcons): string => {
|
||||
switch (kind) {
|
||||
case 'play':
|
||||
return playIcon;
|
||||
case 'pause':
|
||||
return pauseIcon;
|
||||
case 'next':
|
||||
return nextIcon;
|
||||
case 'previous':
|
||||
return previousIcon;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
registerCallback((songInfo) => {
|
||||
// Update currentsonginfo for win.on('show')
|
||||
currentSongInfo = songInfo;
|
||||
// Update thumbar
|
||||
setThumbar(songInfo);
|
||||
});
|
||||
|
||||
// Need to set thumbar again after win.show
|
||||
window.on('show', () => {
|
||||
setThumbar(currentSongInfo);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
import { BrowserWindow, nativeImage } from 'electron';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import getSongControls from '../../providers/song-controls';
|
||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
||||
import { mediaIcons } from '../../types/media-icons';
|
||||
|
||||
import playIcon from '../../../assets/media-icons-black/play.png?asset&asarUnpack';
|
||||
import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnpack';
|
||||
import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack';
|
||||
import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack';
|
||||
|
||||
export default builder.createMain(() => {
|
||||
return {
|
||||
onLoad(win) {
|
||||
let currentSongInfo: SongInfo;
|
||||
|
||||
const { playPause, next, previous } = getSongControls(win);
|
||||
|
||||
const setThumbar = (win: BrowserWindow, songInfo: SongInfo) => {
|
||||
// Wait for song to start before setting thumbar
|
||||
if (!songInfo?.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Win32 require full rewrite of components
|
||||
win.setThumbarButtons([
|
||||
{
|
||||
tooltip: 'Previous',
|
||||
icon: nativeImage.createFromPath(get('previous')),
|
||||
click() {
|
||||
previous();
|
||||
},
|
||||
}, {
|
||||
tooltip: 'Play/Pause',
|
||||
// Update icon based on play state
|
||||
icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
|
||||
click() {
|
||||
playPause();
|
||||
},
|
||||
}, {
|
||||
tooltip: 'Next',
|
||||
icon: nativeImage.createFromPath(get('next')),
|
||||
click() {
|
||||
next();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Util
|
||||
const get = (kind: keyof typeof mediaIcons): string => {
|
||||
switch (kind) {
|
||||
case 'play':
|
||||
return playIcon;
|
||||
case 'pause':
|
||||
return pauseIcon;
|
||||
case 'next':
|
||||
return nextIcon;
|
||||
case 'previous':
|
||||
return previousIcon;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
registerCallback((songInfo) => {
|
||||
// Update currentsonginfo for win.on('show')
|
||||
currentSongInfo = songInfo;
|
||||
// Update thumbar
|
||||
setThumbar(win, songInfo);
|
||||
});
|
||||
|
||||
// Need to set thumbar again after win.show
|
||||
win.on('show', () => {
|
||||
setThumbar(win, currentSongInfo);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,17 +1,98 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { type NativeImage, TouchBar } from 'electron';
|
||||
|
||||
const builder = createPluginBuilder('touchbar', {
|
||||
import { createPlugin } from '@/utils';
|
||||
import getSongControls from '@/providers/song-controls';
|
||||
import registerCallback from '@/providers/song-info';
|
||||
|
||||
export default createPlugin({
|
||||
name: 'TouchBar',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
backend({ window }) {
|
||||
const {
|
||||
TouchBarButton,
|
||||
TouchBarLabel,
|
||||
TouchBarSpacer,
|
||||
TouchBarSegmentedControl,
|
||||
TouchBarScrubber,
|
||||
} = TouchBar;
|
||||
|
||||
export default builder;
|
||||
// Songtitle label
|
||||
const songTitle = new TouchBarLabel({
|
||||
label: '',
|
||||
});
|
||||
// This will store the song controls once available
|
||||
let controls: (() => void)[] = [];
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
// This will store the song image once available
|
||||
const songImage: {
|
||||
icon?: NativeImage;
|
||||
} = {};
|
||||
|
||||
// Pause/play button
|
||||
const pausePlayButton = new TouchBarButton({});
|
||||
|
||||
// The song control buttons (control functions are in the same order)
|
||||
const buttons = new TouchBarSegmentedControl({
|
||||
mode: 'buttons',
|
||||
segments: [
|
||||
new TouchBarButton({
|
||||
label: '⏮',
|
||||
}),
|
||||
pausePlayButton,
|
||||
new TouchBarButton({
|
||||
label: '⏭',
|
||||
}),
|
||||
new TouchBarButton({
|
||||
label: '👎',
|
||||
}),
|
||||
new TouchBarButton({
|
||||
label: '👍',
|
||||
}),
|
||||
],
|
||||
change: (i) => controls[i](),
|
||||
});
|
||||
|
||||
// This is the touchbar object, this combines everything with proper layout
|
||||
const touchBar = new TouchBar({
|
||||
items: [
|
||||
new TouchBarScrubber({
|
||||
items: [songImage, songTitle],
|
||||
continuous: false,
|
||||
}),
|
||||
new TouchBarSpacer({
|
||||
size: 'flexible',
|
||||
}),
|
||||
buttons,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
const { playPause, next, previous, dislike, like } = getSongControls(window);
|
||||
|
||||
// If the page is ready, register the callback
|
||||
window.once('ready-to-show', () => {
|
||||
controls = [previous, playPause, next, dislike, like];
|
||||
|
||||
// Register the callback
|
||||
registerCallback((songInfo) => {
|
||||
// Song information changed, so lets update the touchBar
|
||||
|
||||
// Set the song title
|
||||
songTitle.label = songInfo.title;
|
||||
|
||||
// Changes the pause button if paused
|
||||
pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸';
|
||||
|
||||
// Get image source
|
||||
songImage.icon = songInfo.image
|
||||
? songInfo.image.resize({ height: 23 })
|
||||
: undefined;
|
||||
|
||||
window.setTouchBar(touchBar);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
import { TouchBar, NativeImage } from 'electron';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import registerCallback from '../../providers/song-info';
|
||||
import getSongControls from '../../providers/song-controls';
|
||||
|
||||
export default builder.createMain(() => {
|
||||
return {
|
||||
onLoad(win) {
|
||||
const {
|
||||
TouchBarButton,
|
||||
TouchBarLabel,
|
||||
TouchBarSpacer,
|
||||
TouchBarSegmentedControl,
|
||||
TouchBarScrubber,
|
||||
} = TouchBar;
|
||||
|
||||
// Songtitle label
|
||||
const songTitle = new TouchBarLabel({
|
||||
label: '',
|
||||
});
|
||||
// This will store the song controls once available
|
||||
let controls: (() => void)[] = [];
|
||||
|
||||
// This will store the song image once available
|
||||
const songImage: {
|
||||
icon?: NativeImage;
|
||||
} = {};
|
||||
|
||||
// Pause/play button
|
||||
const pausePlayButton = new TouchBarButton({});
|
||||
|
||||
// The song control buttons (control functions are in the same order)
|
||||
const buttons = new TouchBarSegmentedControl({
|
||||
mode: 'buttons',
|
||||
segments: [
|
||||
new TouchBarButton({
|
||||
label: '⏮',
|
||||
}),
|
||||
pausePlayButton,
|
||||
new TouchBarButton({
|
||||
label: '⏭',
|
||||
}),
|
||||
new TouchBarButton({
|
||||
label: '👎',
|
||||
}),
|
||||
new TouchBarButton({
|
||||
label: '👍',
|
||||
}),
|
||||
],
|
||||
change: (i) => controls[i](),
|
||||
});
|
||||
|
||||
// This is the touchbar object, this combines everything with proper layout
|
||||
const touchBar = new TouchBar({
|
||||
items: [
|
||||
new TouchBarScrubber({
|
||||
items: [songImage, songTitle],
|
||||
continuous: false,
|
||||
}),
|
||||
new TouchBarSpacer({
|
||||
size: 'flexible',
|
||||
}),
|
||||
buttons,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
const { playPause, next, previous, dislike, like } = getSongControls(win);
|
||||
|
||||
// If the page is ready, register the callback
|
||||
win.once('ready-to-show', () => {
|
||||
controls = [previous, playPause, next, dislike, like];
|
||||
|
||||
// Register the callback
|
||||
registerCallback((songInfo) => {
|
||||
// Song information changed, so lets update the touchBar
|
||||
|
||||
// Set the song title
|
||||
songTitle.label = songInfo.title;
|
||||
|
||||
// Changes the pause button if paused
|
||||
pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸';
|
||||
|
||||
// Get image source
|
||||
songImage.icon = songInfo.image
|
||||
? songInfo.image.resize({ height: 23 })
|
||||
: undefined;
|
||||
|
||||
win.setTouchBar(touchBar);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user