This commit is contained in:
JellyBrick
2023-11-27 04:59:20 +09:00
parent e0a3489640
commit 11d06c50a5
26 changed files with 817 additions and 836 deletions

View File

@ -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];
}; };

View File

@ -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);

View File

@ -25,6 +25,9 @@ const createContext = <Config extends PluginConfig>(id: string): RendererContext
listener(...args); listener(...args);
}); });
}, },
removeAllListeners: (event: string) => {
window.ipcRenderer.removeAllListeners(event);
}
}, },
}); });

View File

@ -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;
},
},
}); });

View File

@ -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;
}
};
});

View File

@ -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();
}
}
},
});

View File

@ -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();
}
}
};
});

View File

@ -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);
});
}
}
});

View File

@ -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);
});
}
}));

View File

@ -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);
}
},
},
];
});

View File

@ -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;
},
};
});

View File

@ -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;
}
}

View File

@ -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,
});
},
},
];
});

View File

@ -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;
}
};
});

View File

@ -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;
}
}

View File

@ -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();
};

View File

@ -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:',

View File

@ -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;
}
}

View File

@ -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) => {

View File

@ -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 }) => {
}, },
}, },
]; ];
}); };

View File

@ -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,
}); });
}, };
};
});

View File

@ -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);
},
});
} }
} }
});

View File

@ -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();
},
}));

View File

@ -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;
}; };
} }

View File

@ -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;

View File

@ -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,