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 { 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: () =>
deepmerge(
mainPlugins[id].config,
@ -33,6 +33,9 @@ const createContext = (id: string, win: BrowserWindow): BackendContext => ({
listener(...args);
});
},
removeHandler: (event: string) => {
ipcMain.removeHandler(event);
}
},
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];
};

View File

@ -44,7 +44,7 @@ export const loadAllMenuPlugins = (win: BrowserWindow) => {
const pluginConfigs = config.plugins.getPlugins();
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) {
forceLoadMenuPlugin(pluginId, win);

View File

@ -25,6 +25,9 @@ const createContext = <Config extends PluginConfig>(id: string): RendererContext
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 { 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',
config: {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
},
menu({ getConfig, setConfig }) {
const config = getConfig();
async menu({ getConfig, setConfig }) {
const config = await getConfig();
return [
{
label: 'Automatically select last used caption',
@ -33,22 +73,108 @@ export default createPlugin({
];
},
backend({ ipc: { handle }, win }) {
handle(
'captionsSelector',
async (_, captionLabels: Record<string, string>, currentIndex: string) =>
await prompt(
{
title: 'Choose Caption',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
type: 'select',
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
win,
),
);
backend: {
start({ ipc: { handle }, window }) {
handle(
'captionsSelector',
async (captionLabels: Record<string, string>, currentIndex: string) =>
await prompt(
{
title: 'Choose Caption',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
type: 'select',
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
window,
),
);
},
stop({ ipc: { removeHandler } }) {
removeHandler('captionsSelector');
}
},
renderer: {
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',
restartNeeded: false,
config: {
enabled: false,
},
renderer: {
getCompactSidebar: () => document.querySelector('#mini-guide'),
isCompactSidebarDisabled() {
const compactSidebar = this.getCompactSidebar();
return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none';
},
start() {
if (this.isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
},
stop() {
if (this.isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
},
onConfigChange() {
if (this.isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
}
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

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 = {
enabled: boolean;
@ -8,7 +20,15 @@ export type CrossfadePluginConfig = {
fadeScaling: 'linear' | 'logarithmic' | number;
}
const builder = createPluginBuilder('crossfade', {
export default createPlugin<
unknown,
unknown,
{
config: CrossfadePluginConfig | null;
ipc: RendererContext<CrossfadePluginConfig>['ipc'] | null;
},
CrossfadePluginConfig
>({
name: 'Crossfade [beta]',
restartNeeded: true,
config: {
@ -38,13 +58,241 @@ const builder = createPluginBuilder('crossfade', {
* @default 'linear'
*/
fadeScaling: 'linear',
} as CrossfadePluginConfig,
});
},
menu({ window, getConfig, setConfig }) {
const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
const res = await prompt(
{
title: 'Crossfade Options',
type: 'multiInput',
multiInputOptions: [
{
label: 'Fade in duration (ms)',
value: options.fadeInDuration,
inputAttrs: {
type: 'number',
required: true,
min: '0',
step: '100',
},
},
{
label: 'Fade out duration (ms)',
value: options.fadeOutDuration,
inputAttrs: {
type: 'number',
required: true,
min: '0',
step: '100',
},
},
{
label: 'Crossfade x seconds before end',
value:
options.secondsBeforeEnd,
inputAttrs: {
type: 'number',
required: true,
min: '0',
},
},
{
label: 'Fade scaling',
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
value: options.fadeScaling,
},
],
resizable: true,
height: 360,
...promptOptions(),
},
win,
).catch(console.error);
export default builder;
if (!res) {
return undefined;
}
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
let fadeScaling: 'linear' | 'logarithmic' | number;
if (res[3] === 'linear' || res[3] === 'logarithmic') {
fadeScaling = res[3];
} else if (isFinite(Number(res[3]))) {
fadeScaling = Number(res[3]);
} else {
fadeScaling = options.fadeScaling;
}
return {
fadeInDuration: Number(res[0]),
fadeOutDuration: Number(res[1]),
secondsBeforeEnd: Number(res[2]),
fadeScaling,
};
};
return [
{
label: 'Advanced',
async click() {
const newOptions = await promptCrossfadeValues(window, await getConfig());
if (newOptions) {
setConfig(newOptions);
}
},
},
];
},
async backend({ ipc }) {
const yt = await Innertube.create({
fetch: getNetFetchAsFetch(),
});
ipc.handle('audio-url', async (videoID: string) => {
const info = await yt.getBasicInfo(videoID);
return info.streaming_data?.formats[0].decipher(yt.session.player);
});
},
renderer: {
config: null,
ipc: null,
start({ ipc }) {
this.ipc = ipc;
},
onConfigChange(newConfig) {
this.config = newConfig;
},
onPlayerApiReady() {
let transitionAudio: Howl; // Howler audio used to fade out the current music
let firstVideo = true;
let waitForTransition: Promise<unknown>;
const getStreamURL = async (videoID: string): Promise<string> => this.ipc?.invoke('audio-url', videoID);
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
const watchVideoIDChanges = (cb: (id: string) => void) => {
window.navigation.addEventListener('navigate', (event) => {
const currentVideoID = getVideoIDFromURL(
(event.currentTarget as Navigation).currentEntry?.url ?? '',
);
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
if (
nextVideoID
&& currentVideoID
&& (firstVideo || nextVideoID !== currentVideoID)
) {
if (isReadyToCrossfade()) {
crossfade(() => {
cb(nextVideoID);
});
} else {
cb(nextVideoID);
firstVideo = false;
}
}
});
};
const createAudioForCrossfade = (url: string) => {
if (transitionAudio) {
transitionAudio.unload();
}
transitionAudio = new Howl({
src: url,
html5: true,
volume: 0,
});
syncVideoWithTransitionAudio();
};
const syncVideoWithTransitionAudio = () => {
const video = document.querySelector('video')!;
const videoFader = new VolumeFader(video, {
fadeScaling: this.config?.fadeScaling,
fadeDuration: this.config?.fadeInDuration,
});
transitionAudio.play();
transitionAudio.seek(video.currentTime);
video.addEventListener('seeking', () => {
transitionAudio.seek(video.currentTime);
});
video.addEventListener('pause', () => {
transitionAudio.pause();
});
video.addEventListener('play', () => {
transitionAudio.play();
transitionAudio.seek(video.currentTime);
// Fade in
const videoVolume = video.volume;
video.volume = 0;
videoFader.fadeTo(videoVolume);
});
// Exit just before the end for the transition
const transitionBeforeEnd = () => {
if (
video.currentTime >= video.duration - this.config!.secondsBeforeEnd
&& isReadyToCrossfade()
) {
video.removeEventListener('timeupdate', transitionBeforeEnd);
// Go to next video - XXX: does not support "repeat 1" mode
document.querySelector<HTMLButtonElement>('.next-button')?.click();
}
};
video.addEventListener('timeupdate', transitionBeforeEnd);
};
const crossfade = (cb: () => void) => {
if (!isReadyToCrossfade()) {
cb();
return;
}
let resolveTransition: () => void;
waitForTransition = new Promise<void>((resolve) => {
resolveTransition = resolve;
});
const video = document.querySelector('video')!;
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume,
fadeScaling: this.config?.fadeScaling,
fadeDuration: this.config?.fadeOutDuration,
});
// Fade out the music
video.volume = 0;
fader.fadeOut(() => {
resolveTransition();
cb();
});
};
watchVideoIDChanges(async (videoID) => {
await waitForTransition;
const url = await getStreamURL(videoID);
if (!url) {
return;
}
createAudioForCrossfade(url);
});
}
}
}
});

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 = {
enabled: boolean;
applyOnce: boolean;
}
const builder = createPluginBuilder('disable-autoplay', {
export default createPlugin<
unknown,
unknown,
{
config: DisableAutoPlayPluginConfig | null;
api: YoutubePlayer | null;
eventListener: (name: string) => void;
timeUpdateListener: (e: Event) => void;
},
DisableAutoPlayPluginConfig
>({
name: 'Disable Autoplay',
restartNeeded: false,
config: {
enabled: false,
applyOnce: false,
} as DisableAutoPlayPluginConfig,
},
menu: async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{
label: 'Applies only on startup',
type: 'checkbox',
checked: config.applyOnce,
async click() {
const nowConfig = await getConfig();
setConfig({
applyOnce: !nowConfig.applyOnce,
});
},
},
];
},
renderer: {
config: null,
api: null,
eventListener(name: string) {
if (this.config?.applyOnce) {
this.api?.removeEventListener('videodatachange', this.eventListener);
}
if (name === 'dataloaded') {
this.api?.pauseVideo();
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', this.timeUpdateListener, { once: true });
}
},
timeUpdateListener(e: Event) {
if (e.target instanceof HTMLVideoElement) {
e.target.pause();
}
},
onPlayerApiReady(api) {
this.api = api;
api.addEventListener('videodatachange', this.eventListener);
},
stop() {
this.api?.removeEventListener('videodatachange', this.eventListener);
},
onConfigChange(newConfig) {
this.config = newConfig;
}
}
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

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 = {
enabled: boolean;
@ -32,7 +34,7 @@ export type DiscordPluginConfig = {
hideDurationLeft: boolean;
}
const builder = createPluginBuilder('discord', {
export default createPlugin({
name: 'Discord Rich Presence',
restartNeeded: false,
config: {
@ -44,12 +46,12 @@ const builder = createPluginBuilder('discord', {
hideGitHubButton: false,
hideDurationLeft: false,
} as DiscordPluginConfig,
menu: onMenu,
backend: {
async start({ window, getConfig }) {
await onLoad(window, await getConfig());
},
stop: onUnload,
}
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

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

View File

@ -2,22 +2,20 @@ import prompt from 'custom-electron-prompt';
import { clear, connect, isConnected, registerRefresh } from './main';
import builder from './index';
import { singleton } from '@/providers/decorators';
import { 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 { singleton } from '../../providers/decorators';
import type { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
import type { MenuTemplate } from '@/menu';
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
registerRefresh(refreshMenu);
});
type DiscordOptions = ConfigType<'discord'>;
export default builder.createMenu(async ({ window, getConfig, setConfig, refresh }): Promise<MenuTemplate> => {
export const onMenu = async ({ window, getConfig, setConfig, refresh }: MenuContext<DiscordPluginConfig>): Promise<MenuTemplate> => {
const config = await getConfig();
registerRefreshOnce(refresh);
@ -86,9 +84,9 @@ export default builder.createMenu(async ({ window, getConfig, setConfig, refresh
click: () => setInactivityTimeout(window, config),
},
];
});
};
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordPluginConfig) {
const output = await prompt({
title: 'Set Inactivity Timeout',
label: 'Enter inactivity timeout in seconds:',

View File

@ -2,7 +2,9 @@ import { DefaultPresetList, Preset } from './types';
import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder';
import { createPlugin } from '@/utils';
import { onConfigChange, onMainLoad } from '@/plugins/downloader/main';
import { onPlayerApiReady, onRendererLoad } from '@/plugins/downloader/renderer';
export type DownloaderPluginConfig = {
enabled: boolean;
@ -13,7 +15,7 @@ export type DownloaderPluginConfig = {
playlistMaxItems?: number;
}
const builder = createPluginBuilder('downloader', {
export default createPlugin({
name: 'Downloader',
restartNeeded: true,
config: {
@ -24,13 +26,14 @@ const builder = createPluginBuilder('downloader', {
skipExisting: false,
playlistMaxItems: undefined,
} 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,
} 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 builder, { 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 { DownloaderPluginConfig } from '../index';
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
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 TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
import type { GetPlayerResponse } from '../../../types/get-player-response';
import type { GetPlayerResponse } from '@/types/get-player-response';
type CustomSongInfo = SongInfo & { trackId?: string };
@ -89,31 +90,28 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
.join(';');
};
let config: DownloaderPluginConfig = builder.config;
let config: DownloaderPluginConfig;
export default builder.createMain(({ handle, getConfig, on }) => {
return {
async onLoad(_win) {
win = _win;
config = await getConfig();
export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext<DownloaderPluginConfig>) => {
win = _win;
config = await getConfig();
yt = await Innertube.create({
cache: new UniversalCache(false),
cookie: await getCookieFromWindow(win),
generate_session_locally: true,
fetch: getNetFetchAsFetch(),
});
handle('download-song', (url: string) => downloadSong(url));
on('video-src-changed', (data: GetPlayerResponse) => {
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
});
handle('download-playlist-request', async (_event, url: string) => downloadPlaylist(url));
},
onConfigChange(newConfig) {
config = newConfig;
}
};
});
yt = await Innertube.create({
cache: new UniversalCache(false),
cookie: await getCookieFromWindow(win),
generate_session_locally: true,
fetch: getNetFetchAsFetch(),
});
ipc.handle('download-song', (url: string) => downloadSong(url));
ipc.on('video-src-changed', (data: GetPlayerResponse) => {
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
});
ipc.handle('download-playlist-request', async (url: string) => downloadPlaylist(url));
};
export const onConfigChange = (newConfig: DownloaderPluginConfig) => {
config = newConfig;
};
export async function downloadSong(
url: string,
@ -319,7 +317,7 @@ async function iterableStreamToTargetFile(
contentLength: number,
sendFeedback: (str: string, value?: number) => void,
increasePlaylistProgress: (value: number) => void = () => {},
) {
): Promise<Uint8Array | null> {
const chunks = [];
let downloaded = 0;
for await (const chunk of stream) {
@ -379,6 +377,7 @@ async function iterableStreamToTargetFile(
} finally {
releaseFFmpegMutex();
}
return null;
}
const getCoverBuffer = cache(async (url: string) => {

View File

@ -4,9 +4,12 @@ import { downloadPlaylist } from './main';
import { defaultMenuDownloadLabel, getFolder } from './main/utils';
import { DefaultPresetList } from './types';
import builder from './index';
import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu';
export default builder.createMenu(async ({ getConfig, setConfig }) => {
import type { DownloaderPluginConfig } from './index';
export const onMenu = async ({ getConfig, setConfig }: MenuContext<DownloaderPluginConfig>): Promise<MenuTemplate> => {
const config = await getConfig();
return [
@ -46,4 +49,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
},
},
];
});
};

View File

@ -1,11 +1,14 @@
import downloadHTML from './templates/download.html?raw';
import builder from './index';
import defaultConfig from '@/config/defaults';
import { getSongMenu } from '@/providers/dom-elements';
import { getSongInfo } from '@/providers/song-info-front';
import defaultConfig from '../../config/defaults';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils/renderer';
import { getSongInfo } from '../../providers/song-info-front';
import type { RendererContext } from '@/types/contexts';
import type { DownloaderPluginConfig } from './index';
let menu: Element | null = null;
let progress: Element | null = null;
@ -13,70 +16,67 @@ const downloadButton = ElementFromHtml(downloadHTML);
let doneFirstLoad = false;
export default builder.createRenderer(({ invoke, on }) => {
const menuObserver = new MutationObserver(() => {
const menuObserver = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) {
menu = getSongMenu();
if (!menu) {
return;
}
}
if (menu.contains(downloadButton)) {
return;
}
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
return;
}
menu.prepend(downloadButton);
progress = document.querySelector('#ytmcustom-download');
if (doneFirstLoad) {
return;
}
setTimeout(() => doneFirstLoad ||= true, 500);
});
export const onRendererLoad = ({ ipc }: RendererContext<DownloaderPluginConfig>) => {
window.download = () => {
let videoUrl = getSongMenu()
// Selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
?.getAttribute('href');
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = defaultConfig.url + '/' + videoUrl;
}
if (videoUrl.includes('?playlist=')) {
ipc.invoke('download-playlist-request', videoUrl);
return;
}
} else {
videoUrl = getSongInfo().url || window.location.href;
}
if (menu.contains(downloadButton)) {
return;
}
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
return;
}
menu.prepend(downloadButton);
progress = document.querySelector('#ytmcustom-download');
if (doneFirstLoad) {
return;
}
setTimeout(() => doneFirstLoad ||= true, 500);
});
return {
onLoad() {
window.download = () => {
let videoUrl = getSongMenu()
// Selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
?.getAttribute('href');
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = defaultConfig.url + '/' + videoUrl;
}
if (videoUrl.includes('?playlist=')) {
invoke('download-playlist-request', videoUrl);
return;
}
} else {
videoUrl = getSongInfo().url || window.location.href;
}
invoke('download-song', videoUrl);
};
on('downloader-feedback', (feedback: string) => {
if (progress) {
progress.innerHTML = feedback || 'Download';
} else {
console.warn('Cannot update progress');
}
});
},
onPlayerApiReady() {
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true,
subtree: true,
});
},
ipc.invoke('download-song', videoUrl);
};
});
ipc.on('downloader-feedback', (feedback: string) => {
if (progress) {
progress.innerHTML = feedback || 'Download';
} else {
console.warn('Cannot update progress');
}
});
};
export const onPlayerApiReady = () => {
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true,
subtree: true,
});
};

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

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';
export interface BaseContext<Config extends PluginConfig> {
getConfig(): Promise<Config>;
setConfig(conf: Partial<Omit<Config, 'enabled'>>): void;
getConfig(): Promise<Config> | Config;
setConfig(conf: Partial<Omit<Config, 'enabled'>>): Promise<void> | void;
}
export interface BackendContext<Config extends PluginConfig> extends BaseContext<Config> {
ipc: {
send: (event: string, ...args: unknown[]) => void;
send: WebContents['send'];
handle: (event: string, listener: CallableFunction) => void;
on: (event: string, listener: CallableFunction) => void;
removeHandler: IpcMain['removeHandler'];
};
window: BrowserWindow;
@ -25,8 +26,9 @@ export interface PreloadContext<Config extends PluginConfig> extends BaseContext
export interface RendererContext<Config extends PluginConfig> extends BaseContext<Config> {
ipc: {
send: (event: string, ...args: unknown[]) => void;
invoke: (event: string, ...args: unknown[]) => Promise<unknown>;
send: IpcRenderer['send'];
invoke: IpcRenderer['invoke'];
on: (event: string, listener: CallableFunction) => void;
removeAllListeners: (event: string) => void;
};
}

View File

@ -34,7 +34,7 @@ export interface PluginDef<
description?: string;
config?: Config;
menu?: (ctx: MenuContext<Config>) => Promise<Electron.MenuItemConstructorOptions[]>;
menu?: (ctx: MenuContext<Config>) => Promise<Electron.MenuItemConstructorOptions[]> | Electron.MenuItemConstructorOptions[];
stylesheets?: string[];
restartNeeded?: boolean;

View File

@ -13,7 +13,7 @@ export const createPlugin = <
BackendProperties,
PreloadProperties,
RendererProperties,
Config extends PluginConfig,
Config extends PluginConfig = PluginConfig,
>(
def: PluginDef<
BackendProperties,