mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 18:41:47 +00:00
WIP 2
This commit is contained in:
@ -8,9 +8,9 @@ import { BackendContext } from '@/types/contexts';
|
|||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { startPlugin, stopPlugin } from '@/utils';
|
import { startPlugin, stopPlugin } from '@/utils';
|
||||||
|
|
||||||
const loadedPluginMap: Record<string, PluginDef> = {};
|
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
||||||
|
|
||||||
const createContext = (id: string, win: BrowserWindow): BackendContext => ({
|
const createContext = (id: string, win: BrowserWindow): BackendContext<PluginConfig> => ({
|
||||||
getConfig: () =>
|
getConfig: () =>
|
||||||
deepmerge(
|
deepmerge(
|
||||||
mainPlugins[id].config,
|
mainPlugins[id].config,
|
||||||
@ -33,6 +33,9 @@ const createContext = (id: string, win: BrowserWindow): BackendContext => ({
|
|||||||
listener(...args);
|
listener(...args);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
removeHandler: (event: string) => {
|
||||||
|
ipcMain.removeHandler(event);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
window: win,
|
window: win,
|
||||||
@ -123,7 +126,7 @@ export const unloadAllMainPlugins = (win: BrowserWindow) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLoadedMainPlugin = (id: string): PluginDef | undefined => {
|
export const getLoadedMainPlugin = (id: string): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||||
return loadedPluginMap[id];
|
return loadedPluginMap[id];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export const loadAllMenuPlugins = (win: BrowserWindow) => {
|
|||||||
const pluginConfigs = config.plugins.getPlugins();
|
const pluginConfigs = config.plugins.getPlugins();
|
||||||
|
|
||||||
for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
|
for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
|
||||||
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}) as PluginConfig;
|
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
|
||||||
|
|
||||||
if (config.enabled) {
|
if (config.enabled) {
|
||||||
forceLoadMenuPlugin(pluginId, win);
|
forceLoadMenuPlugin(pluginId, win);
|
||||||
|
|||||||
@ -25,6 +25,9 @@ const createContext = <Config extends PluginConfig>(id: string): RendererContext
|
|||||||
listener(...args);
|
listener(...args);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
removeAllListeners: (event: string) => {
|
||||||
|
window.ipcRenderer.removeAllListeners(event);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2,17 +2,57 @@ import prompt from 'custom-electron-prompt';
|
|||||||
|
|
||||||
import promptOptions from '@/providers/prompt-options';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
import { createPlugin } from '@/utils';
|
import { createPlugin } from '@/utils';
|
||||||
|
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||||
|
|
||||||
export default createPlugin({
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
captionTrackList: LanguageOptions[] | null;
|
||||||
|
api: YoutubePlayer | null;
|
||||||
|
config: CaptionsSelectorConfig | null;
|
||||||
|
setConfig: ((config: Partial<CaptionsSelectorConfig>) => void);
|
||||||
|
videoChangeListener: (() => void);
|
||||||
|
captionsButtonClickListener: (() => void);
|
||||||
|
},
|
||||||
|
CaptionsSelectorConfig
|
||||||
|
>({
|
||||||
name: 'Captions Selector',
|
name: 'Captions Selector',
|
||||||
config: {
|
config: {
|
||||||
|
enabled: false,
|
||||||
disableCaptions: false,
|
disableCaptions: false,
|
||||||
autoload: false,
|
autoload: false,
|
||||||
lastCaptionsCode: '',
|
lastCaptionsCode: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
menu({ getConfig, setConfig }) {
|
async menu({ getConfig, setConfig }) {
|
||||||
const config = getConfig();
|
const config = await getConfig();
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Automatically select last used caption',
|
label: 'Automatically select last used caption',
|
||||||
@ -33,10 +73,11 @@ export default createPlugin({
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
backend({ ipc: { handle }, win }) {
|
backend: {
|
||||||
|
start({ ipc: { handle }, window }) {
|
||||||
handle(
|
handle(
|
||||||
'captionsSelector',
|
'captionsSelector',
|
||||||
async (_, captionLabels: Record<string, string>, currentIndex: string) =>
|
async (captionLabels: Record<string, string>, currentIndex: string) =>
|
||||||
await prompt(
|
await prompt(
|
||||||
{
|
{
|
||||||
title: 'Choose Caption',
|
title: 'Choose Caption',
|
||||||
@ -47,8 +88,93 @@ export default createPlugin({
|
|||||||
resizable: true,
|
resizable: true,
|
||||||
...promptOptions(),
|
...promptOptions(),
|
||||||
},
|
},
|
||||||
win,
|
window,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
stop({ ipc: { removeHandler } }) {
|
||||||
|
removeHandler('captionsSelector');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
captionTrackList: null,
|
||||||
|
api: null,
|
||||||
|
config: null,
|
||||||
|
setConfig: () => {},
|
||||||
|
async videoChangeListener() {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
captionsButtonClickListener() {
|
||||||
|
if (this.config!.disableCaptions) {
|
||||||
|
setTimeout(() => this.api!.unloadModule('captions'), 100);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(captionsSettingsButton);
|
||||||
|
document.querySelector<YoutubePlayer & HTMLElement>('#movie_player')?.unloadModule('captions');
|
||||||
|
document.querySelector('video')?.removeEventListener('srcChanged', this.videoChangeListener);
|
||||||
|
captionsSettingsButton.removeEventListener('click', this.captionsButtonClickListener);
|
||||||
|
},
|
||||||
|
onPlayerApiReady(playerApi) {
|
||||||
|
this.api = playerApi;
|
||||||
|
|
||||||
|
document.querySelector('.right-controls-buttons')?.append(captionsSettingsButton);
|
||||||
|
|
||||||
|
this.captionTrackList = this.api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
|
||||||
|
|
||||||
|
document.querySelector('video')?.addEventListener('srcChanged', this.videoChangeListener);
|
||||||
|
captionsSettingsButton.addEventListener('click', this.captionsButtonClickListener);
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
name: 'Compact Sidebar',
|
||||||
restartNeeded: false,
|
restartNeeded: false,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
});
|
renderer: {
|
||||||
|
getCompactSidebar: () => document.querySelector('#mini-guide'),
|
||||||
export default builder;
|
isCompactSidebarDisabled() {
|
||||||
|
const compactSidebar = this.getCompactSidebar();
|
||||||
declare global {
|
return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none';
|
||||||
interface PluginBuilderList {
|
},
|
||||||
[builder.id]: typeof builder;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -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 '@/plugins/crossfade/fader';
|
||||||
|
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
|
||||||
export type CrossfadePluginConfig = {
|
export type CrossfadePluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -8,7 +20,15 @@ export type CrossfadePluginConfig = {
|
|||||||
fadeScaling: 'linear' | 'logarithmic' | number;
|
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]',
|
name: 'Crossfade [beta]',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -38,13 +58,241 @@ const builder = createPluginBuilder('crossfade', {
|
|||||||
* @default 'linear'
|
* @default 'linear'
|
||||||
*/
|
*/
|
||||||
fadeScaling: '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 = {
|
export type DisableAutoPlayPluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
applyOnce: 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',
|
name: 'Disable Autoplay',
|
||||||
restartNeeded: false,
|
restartNeeded: false,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
applyOnce: 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 '@/plugins/discord/main';
|
||||||
|
import {onMenu} from "@/plugins/discord/menu";
|
||||||
|
|
||||||
export type DiscordPluginConfig = {
|
export type DiscordPluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -32,7 +34,7 @@ export type DiscordPluginConfig = {
|
|||||||
hideDurationLeft: boolean;
|
hideDurationLeft: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = createPluginBuilder('discord', {
|
export default createPlugin({
|
||||||
name: 'Discord Rich Presence',
|
name: 'Discord Rich Presence',
|
||||||
restartNeeded: false,
|
restartNeeded: false,
|
||||||
config: {
|
config: {
|
||||||
@ -44,12 +46,12 @@ const builder = createPluginBuilder('discord', {
|
|||||||
hideGitHubButton: false,
|
hideGitHubButton: false,
|
||||||
hideDurationLeft: false,
|
hideDurationLeft: false,
|
||||||
} as DiscordPluginConfig,
|
} 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,10 +4,11 @@ import { dev } from 'electron-is';
|
|||||||
|
|
||||||
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
|
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
|
||||||
|
|
||||||
import builder from './index';
|
|
||||||
|
|
||||||
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
|
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
|
||||||
|
|
||||||
|
import type { DiscordPluginConfig } from './index';
|
||||||
|
|
||||||
|
|
||||||
// Application ID registered by @Zo-Bro-23
|
// Application ID registered by @Zo-Bro-23
|
||||||
const clientId = '1043858434585526382';
|
const clientId = '1043858434585526382';
|
||||||
|
|
||||||
@ -92,12 +93,18 @@ export const connect = (showError = false) => {
|
|||||||
|
|
||||||
let clearActivity: NodeJS.Timeout | undefined;
|
let clearActivity: NodeJS.Timeout | undefined;
|
||||||
let updateActivity: SongInfoCallback;
|
let updateActivity: SongInfoCallback;
|
||||||
|
export const clear = () => {
|
||||||
|
if (info.rpc) {
|
||||||
|
info.rpc.user?.clearActivity();
|
||||||
|
}
|
||||||
|
|
||||||
export default builder.createMain(({ getConfig }) => {
|
clearTimeout(clearActivity);
|
||||||
return {
|
};
|
||||||
async onLoad(win) {
|
|
||||||
const options = await getConfig();
|
|
||||||
|
|
||||||
|
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', () => {
|
info.rpc.on('connected', () => {
|
||||||
if (dev()) {
|
if (dev()) {
|
||||||
console.log('discord connected');
|
console.log('discord connected');
|
||||||
@ -214,20 +221,9 @@ export default builder.createMain(({ getConfig }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
app.on('window-all-closed', clear);
|
app.on('window-all-closed', clear);
|
||||||
},
|
|
||||||
onUnload() {
|
|
||||||
resetInfo();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const clear = () => {
|
|
||||||
if (info.rpc) {
|
|
||||||
info.rpc.user?.clearActivity();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(clearActivity);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
|
export const onUnload = () => {
|
||||||
export const isConnected = () => info.rpc !== null;
|
resetInfo();
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,22 +2,20 @@ import prompt from 'custom-electron-prompt';
|
|||||||
|
|
||||||
import { clear, connect, isConnected, registerRefresh } from './main';
|
import { clear, connect, isConnected, registerRefresh } from './main';
|
||||||
|
|
||||||
import builder from './index';
|
import { singleton } from '@/providers/decorators';
|
||||||
|
import { setMenuOptions } from '@/config/plugins';
|
||||||
|
import { MenuContext } from '@/types/contexts';
|
||||||
|
import { DiscordPluginConfig } from '@/plugins/discord/index';
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
import promptOptions from '../../providers/prompt-options';
|
||||||
import { singleton } from '../../providers/decorators';
|
|
||||||
|
|
||||||
import type { MenuTemplate } from '../../menu';
|
import type { MenuTemplate } from '@/menu';
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
||||||
registerRefresh(refreshMenu);
|
registerRefresh(refreshMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
type DiscordOptions = ConfigType<'discord'>;
|
export const onMenu = async ({ window, getConfig, setConfig, refresh }: MenuContext<DiscordPluginConfig>): Promise<MenuTemplate> => {
|
||||||
|
|
||||||
export default builder.createMenu(async ({ window, getConfig, setConfig, refresh }): Promise<MenuTemplate> => {
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
registerRefreshOnce(refresh);
|
registerRefreshOnce(refresh);
|
||||||
|
|
||||||
@ -86,9 +84,9 @@ export default builder.createMenu(async ({ window, getConfig, setConfig, refresh
|
|||||||
click: () => setInactivityTimeout(window, config),
|
click: () => setInactivityTimeout(window, config),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
};
|
||||||
|
|
||||||
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
|
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordPluginConfig) {
|
||||||
const output = await prompt({
|
const output = await prompt({
|
||||||
title: 'Set Inactivity Timeout',
|
title: 'Set Inactivity Timeout',
|
||||||
label: 'Enter inactivity timeout in seconds:',
|
label: 'Enter inactivity timeout in seconds:',
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import { DefaultPresetList, Preset } from './types';
|
|||||||
|
|
||||||
import style from './style.css?inline';
|
import style from './style.css?inline';
|
||||||
|
|
||||||
import { createPluginBuilder } from '../utils/builder';
|
import { createPlugin } from '@/utils';
|
||||||
|
import { onConfigChange, onMainLoad } from '@/plugins/downloader/main';
|
||||||
|
import { onPlayerApiReady, onRendererLoad } from '@/plugins/downloader/renderer';
|
||||||
|
|
||||||
export type DownloaderPluginConfig = {
|
export type DownloaderPluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -13,7 +15,7 @@ export type DownloaderPluginConfig = {
|
|||||||
playlistMaxItems?: number;
|
playlistMaxItems?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = createPluginBuilder('downloader', {
|
export default createPlugin({
|
||||||
name: 'Downloader',
|
name: 'Downloader',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -24,13 +26,14 @@ const builder = createPluginBuilder('downloader', {
|
|||||||
skipExisting: false,
|
skipExisting: false,
|
||||||
playlistMaxItems: undefined,
|
playlistMaxItems: undefined,
|
||||||
} as DownloaderPluginConfig,
|
} as DownloaderPluginConfig,
|
||||||
styles: [style],
|
stylesheets: [style],
|
||||||
|
backend: {
|
||||||
|
start: onMainLoad,
|
||||||
|
onConfigChange,
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
start: onRendererLoad,
|
||||||
|
onPlayerApiReady,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default builder;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -28,15 +28,16 @@ import {
|
|||||||
setBadge,
|
setBadge,
|
||||||
} from './utils';
|
} 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 { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
|
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
|
||||||
|
|
||||||
import builder, { DownloaderPluginConfig } from '../index';
|
import { 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 { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||||
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
||||||
@ -44,7 +45,7 @@ import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic';
|
|||||||
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
||||||
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
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 };
|
type CustomSongInfo = SongInfo & { trackId?: string };
|
||||||
|
|
||||||
@ -89,11 +90,9 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
|
|||||||
.join(';');
|
.join(';');
|
||||||
};
|
};
|
||||||
|
|
||||||
let config: DownloaderPluginConfig = builder.config;
|
let config: DownloaderPluginConfig;
|
||||||
|
|
||||||
export default builder.createMain(({ handle, getConfig, on }) => {
|
export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext<DownloaderPluginConfig>) => {
|
||||||
return {
|
|
||||||
async onLoad(_win) {
|
|
||||||
win = _win;
|
win = _win;
|
||||||
config = await getConfig();
|
config = await getConfig();
|
||||||
|
|
||||||
@ -103,17 +102,16 @@ export default builder.createMain(({ handle, getConfig, on }) => {
|
|||||||
generate_session_locally: true,
|
generate_session_locally: true,
|
||||||
fetch: getNetFetchAsFetch(),
|
fetch: getNetFetchAsFetch(),
|
||||||
});
|
});
|
||||||
handle('download-song', (url: string) => downloadSong(url));
|
ipc.handle('download-song', (url: string) => downloadSong(url));
|
||||||
on('video-src-changed', (data: GetPlayerResponse) => {
|
ipc.on('video-src-changed', (data: GetPlayerResponse) => {
|
||||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||||
});
|
});
|
||||||
handle('download-playlist-request', async (_event, url: string) => downloadPlaylist(url));
|
ipc.handle('download-playlist-request', async (url: string) => downloadPlaylist(url));
|
||||||
},
|
};
|
||||||
onConfigChange(newConfig) {
|
|
||||||
|
export const onConfigChange = (newConfig: DownloaderPluginConfig) => {
|
||||||
config = newConfig;
|
config = newConfig;
|
||||||
}
|
};
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function downloadSong(
|
export async function downloadSong(
|
||||||
url: string,
|
url: string,
|
||||||
@ -319,7 +317,7 @@ async function iterableStreamToTargetFile(
|
|||||||
contentLength: number,
|
contentLength: number,
|
||||||
sendFeedback: (str: string, value?: number) => void,
|
sendFeedback: (str: string, value?: number) => void,
|
||||||
increasePlaylistProgress: (value: number) => void = () => {},
|
increasePlaylistProgress: (value: number) => void = () => {},
|
||||||
) {
|
): Promise<Uint8Array | null> {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let downloaded = 0;
|
let downloaded = 0;
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
@ -379,6 +377,7 @@ async function iterableStreamToTargetFile(
|
|||||||
} finally {
|
} finally {
|
||||||
releaseFFmpegMutex();
|
releaseFFmpegMutex();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCoverBuffer = cache(async (url: string) => {
|
const getCoverBuffer = cache(async (url: string) => {
|
||||||
|
|||||||
@ -4,9 +4,12 @@ import { downloadPlaylist } from './main';
|
|||||||
import { defaultMenuDownloadLabel, getFolder } from './main/utils';
|
import { defaultMenuDownloadLabel, getFolder } from './main/utils';
|
||||||
import { DefaultPresetList } from './types';
|
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();
|
const config = await getConfig();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -46,4 +49,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
};
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import downloadHTML from './templates/download.html?raw';
|
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 { 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 menu: Element | null = null;
|
||||||
let progress: Element | null = null;
|
let progress: Element | null = null;
|
||||||
@ -13,8 +16,7 @@ const downloadButton = ElementFromHtml(downloadHTML);
|
|||||||
|
|
||||||
let doneFirstLoad = false;
|
let doneFirstLoad = false;
|
||||||
|
|
||||||
export default builder.createRenderer(({ invoke, on }) => {
|
const menuObserver = new MutationObserver(() => {
|
||||||
const menuObserver = new MutationObserver(() => {
|
|
||||||
if (!menu) {
|
if (!menu) {
|
||||||
menu = getSongMenu();
|
menu = getSongMenu();
|
||||||
if (!menu) {
|
if (!menu) {
|
||||||
@ -39,10 +41,9 @@ export default builder.createRenderer(({ invoke, on }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
export const onRendererLoad = ({ ipc }: RendererContext<DownloaderPluginConfig>) => {
|
||||||
onLoad() {
|
|
||||||
window.download = () => {
|
window.download = () => {
|
||||||
let videoUrl = getSongMenu()
|
let videoUrl = getSongMenu()
|
||||||
// Selector of first button which is always "Start Radio"
|
// Selector of first button which is always "Start Radio"
|
||||||
@ -54,29 +55,28 @@ export default builder.createRenderer(({ invoke, on }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (videoUrl.includes('?playlist=')) {
|
if (videoUrl.includes('?playlist=')) {
|
||||||
invoke('download-playlist-request', videoUrl);
|
ipc.invoke('download-playlist-request', videoUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
videoUrl = getSongInfo().url || window.location.href;
|
videoUrl = getSongInfo().url || window.location.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
invoke('download-song', videoUrl);
|
ipc.invoke('download-song', videoUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
on('downloader-feedback', (feedback: string) => {
|
ipc.on('downloader-feedback', (feedback: string) => {
|
||||||
if (progress) {
|
if (progress) {
|
||||||
progress.innerHTML = feedback || 'Download';
|
progress.innerHTML = feedback || 'Download';
|
||||||
} else {
|
} else {
|
||||||
console.warn('Cannot update progress');
|
console.warn('Cannot update progress');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
onPlayerApiReady() {
|
|
||||||
|
export const onPlayerApiReady = () => {
|
||||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: 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',
|
name: 'Exponential Volume',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
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 {
|
const storedOriginalVolumes = new WeakMap<HTMLMediaElement, number>();
|
||||||
interface PluginBuilderList {
|
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||||
[builder.id]: typeof builder;
|
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,16 +1,17 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { IpcMain, IpcRenderer, WebContents, BrowserWindow } from 'electron';
|
||||||
import type { PluginConfig } from '@/types/plugins';
|
import type { PluginConfig } from '@/types/plugins';
|
||||||
|
|
||||||
export interface BaseContext<Config extends PluginConfig> {
|
export interface BaseContext<Config extends PluginConfig> {
|
||||||
getConfig(): Promise<Config>;
|
getConfig(): Promise<Config> | Config;
|
||||||
setConfig(conf: Partial<Omit<Config, 'enabled'>>): void;
|
setConfig(conf: Partial<Omit<Config, 'enabled'>>): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendContext<Config extends PluginConfig> extends BaseContext<Config> {
|
export interface BackendContext<Config extends PluginConfig> extends BaseContext<Config> {
|
||||||
ipc: {
|
ipc: {
|
||||||
send: (event: string, ...args: unknown[]) => void;
|
send: WebContents['send'];
|
||||||
handle: (event: string, listener: CallableFunction) => void;
|
handle: (event: string, listener: CallableFunction) => void;
|
||||||
on: (event: string, listener: CallableFunction) => void;
|
on: (event: string, listener: CallableFunction) => void;
|
||||||
|
removeHandler: IpcMain['removeHandler'];
|
||||||
};
|
};
|
||||||
|
|
||||||
window: BrowserWindow;
|
window: BrowserWindow;
|
||||||
@ -25,8 +26,9 @@ export interface PreloadContext<Config extends PluginConfig> extends BaseContext
|
|||||||
|
|
||||||
export interface RendererContext<Config extends PluginConfig> extends BaseContext<Config> {
|
export interface RendererContext<Config extends PluginConfig> extends BaseContext<Config> {
|
||||||
ipc: {
|
ipc: {
|
||||||
send: (event: string, ...args: unknown[]) => void;
|
send: IpcRenderer['send'];
|
||||||
invoke: (event: string, ...args: unknown[]) => Promise<unknown>;
|
invoke: IpcRenderer['invoke'];
|
||||||
on: (event: string, listener: CallableFunction) => void;
|
on: (event: string, listener: CallableFunction) => void;
|
||||||
|
removeAllListeners: (event: string) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export interface PluginDef<
|
|||||||
description?: string;
|
description?: string;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
|
|
||||||
menu?: (ctx: MenuContext<Config>) => Promise<Electron.MenuItemConstructorOptions[]>;
|
menu?: (ctx: MenuContext<Config>) => Promise<Electron.MenuItemConstructorOptions[]> | Electron.MenuItemConstructorOptions[];
|
||||||
stylesheets?: string[];
|
stylesheets?: string[];
|
||||||
restartNeeded?: boolean;
|
restartNeeded?: boolean;
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export const createPlugin = <
|
|||||||
BackendProperties,
|
BackendProperties,
|
||||||
PreloadProperties,
|
PreloadProperties,
|
||||||
RendererProperties,
|
RendererProperties,
|
||||||
Config extends PluginConfig,
|
Config extends PluginConfig = PluginConfig,
|
||||||
>(
|
>(
|
||||||
def: PluginDef<
|
def: PluginDef<
|
||||||
BackendProperties,
|
BackendProperties,
|
||||||
|
|||||||
Reference in New Issue
Block a user