mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 10:11:46 +00:00
WIP 2
This commit is contained in:
@ -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];
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -25,6 +25,9 @@ const createContext = <Config extends PluginConfig>(id: string): RendererContext
|
||||
listener(...args);
|
||||
});
|
||||
},
|
||||
removeAllListeners: (event: string) => {
|
||||
window.ipcRenderer.removeAllListeners(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -2,17 +2,57 @@ import prompt from 'custom-electron-prompt';
|
||||
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
import { 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
|
||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
||||
|
||||
interface LanguageOptions {
|
||||
displayName: string;
|
||||
id: string | null;
|
||||
is_default: boolean;
|
||||
is_servable: boolean;
|
||||
is_translateable: boolean;
|
||||
kind: string;
|
||||
languageCode: string; // 2 length
|
||||
languageName: string;
|
||||
name: string | null;
|
||||
vss_id: string;
|
||||
}
|
||||
|
||||
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
|
||||
|
||||
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
|
||||
|
||||
export default builder.createRenderer(({ getConfig, setConfig }) => {
|
||||
let config: Awaited<ReturnType<typeof getConfig>>;
|
||||
let captionTrackList: LanguageOptions[] | null = null;
|
||||
let api: YoutubePlayer;
|
||||
|
||||
const videoChangeListener = () => {
|
||||
if (config.disableCaptions) {
|
||||
setTimeout(() => api.unloadModule('captions'), 100);
|
||||
captionsSettingsButton.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
api.loadModule('captions');
|
||||
|
||||
setTimeout(() => {
|
||||
captionTrackList = api.getOption('captions', 'tracklist') ?? [];
|
||||
|
||||
if (config.autoload && config.lastCaptionsCode) {
|
||||
api.setOption('captions', 'track', {
|
||||
languageCode: config.lastCaptionsCode,
|
||||
});
|
||||
}
|
||||
|
||||
captionsSettingsButton.style.display = captionTrackList?.length
|
||||
? 'inline-block'
|
||||
: 'none';
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const captionsButtonClickListener = async () => {
|
||||
if (captionTrackList?.length) {
|
||||
const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
|
||||
let currentIndex = currentCaptionTrack
|
||||
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!)
|
||||
: null;
|
||||
|
||||
const captionLabels = [
|
||||
...captionTrackList.map((track) => track.displayName),
|
||||
'None',
|
||||
];
|
||||
|
||||
currentIndex = await window.ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
|
||||
if (currentIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCaptions = captionTrackList[currentIndex];
|
||||
setConfig({ lastCaptionsCode: newCaptions?.languageCode });
|
||||
if (newCaptions) {
|
||||
api.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
|
||||
} else {
|
||||
api.setOption('captions', 'track', {});
|
||||
}
|
||||
|
||||
setTimeout(() => api.playVideo());
|
||||
}
|
||||
};
|
||||
|
||||
const removeListener = () => {
|
||||
$('.right-controls-buttons').removeChild(captionsSettingsButton);
|
||||
$<YoutubePlayer & HTMLElement>('#movie_player').unloadModule('captions');
|
||||
};
|
||||
|
||||
return {
|
||||
async onLoad() {
|
||||
config = await getConfig();
|
||||
},
|
||||
onPlayerApiReady(playerApi) {
|
||||
api = playerApi;
|
||||
|
||||
$('.right-controls-buttons').append(captionsSettingsButton);
|
||||
|
||||
captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
|
||||
|
||||
$('video').addEventListener('srcChanged', videoChangeListener);
|
||||
captionsSettingsButton.addEventListener('click', captionsButtonClickListener);
|
||||
},
|
||||
onUnload() {
|
||||
removeListener();
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,17 +1,38 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
const builder = createPluginBuilder('compact-sidebar', {
|
||||
export default createPlugin<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
getCompactSidebar: () => HTMLElement | null;
|
||||
isCompactSidebarDisabled: () => boolean;
|
||||
}
|
||||
>({
|
||||
name: 'Compact Sidebar',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
renderer: {
|
||||
getCompactSidebar: () => document.querySelector('#mini-guide'),
|
||||
isCompactSidebarDisabled() {
|
||||
const compactSidebar = this.getCompactSidebar();
|
||||
return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none';
|
||||
},
|
||||
start() {
|
||||
if (this.isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
if (this.isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
},
|
||||
onConfigChange() {
|
||||
if (this.isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => {
|
||||
const getCompactSidebar = () => document.querySelector('#mini-guide');
|
||||
const isCompactSidebarDisabled = () => {
|
||||
const compactSidebar = getCompactSidebar();
|
||||
return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none';
|
||||
};
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
if (isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
if (!isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
},
|
||||
onConfigChange() {
|
||||
if (isCompactSidebarDisabled()) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,4 +1,16 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { Innertube } from 'youtubei.js';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { Howl } from 'howler';
|
||||
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { VolumeFader } from '@/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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { Innertube } from 'youtubei.js';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import { getNetFetchAsFetch } from '../utils/main';
|
||||
|
||||
export default builder.createMain(({ handle }) => ({
|
||||
async onLoad() {
|
||||
const yt = await Innertube.create({
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
|
||||
handle('audio-url', async (_, videoID: string) => {
|
||||
const info = await yt.getBasicInfo(videoID);
|
||||
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||
});
|
||||
}
|
||||
}));
|
||||
@ -1,91 +0,0 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import builder, { CrossfadePluginConfig } from './index';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
|
||||
export default builder.createMenu(({ window, getConfig, setConfig }) => {
|
||||
const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
|
||||
const res = await prompt(
|
||||
{
|
||||
title: 'Crossfade Options',
|
||||
type: 'multiInput',
|
||||
multiInputOptions: [
|
||||
{
|
||||
label: 'Fade in duration (ms)',
|
||||
value: options.fadeInDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Fade out duration (ms)',
|
||||
value: options.fadeOutDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Crossfade x seconds before end',
|
||||
value:
|
||||
options.secondsBeforeEnd,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Fade scaling',
|
||||
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
|
||||
value: options.fadeScaling,
|
||||
},
|
||||
],
|
||||
resizable: true,
|
||||
height: 360,
|
||||
...promptOptions(),
|
||||
},
|
||||
win,
|
||||
).catch(console.error);
|
||||
|
||||
if (!res) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let fadeScaling: 'linear' | 'logarithmic' | number;
|
||||
if (res[3] === 'linear' || res[3] === 'logarithmic') {
|
||||
fadeScaling = res[3];
|
||||
} else if (isFinite(Number(res[3]))) {
|
||||
fadeScaling = Number(res[3]);
|
||||
} else {
|
||||
fadeScaling = options.fadeScaling;
|
||||
}
|
||||
|
||||
return {
|
||||
fadeInDuration: Number(res[0]),
|
||||
fadeOutDuration: Number(res[1]),
|
||||
secondsBeforeEnd: Number(res[2]),
|
||||
fadeScaling,
|
||||
};
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Advanced',
|
||||
async click() {
|
||||
const newOptions = await promptCrossfadeValues(window, await getConfig());
|
||||
if (newOptions) {
|
||||
setConfig(newOptions);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,153 +0,0 @@
|
||||
import { Howl } from 'howler';
|
||||
|
||||
// Extracted from https://github.com/bitfasching/VolumeFader
|
||||
import { VolumeFader } from './fader';
|
||||
|
||||
import builder, { CrossfadePluginConfig } from './index';
|
||||
|
||||
export default builder.createRenderer(({ getConfig, invoke }) => {
|
||||
let config: CrossfadePluginConfig;
|
||||
|
||||
let transitionAudio: Howl; // Howler audio used to fade out the current music
|
||||
let firstVideo = true;
|
||||
let waitForTransition: Promise<unknown>;
|
||||
|
||||
const getStreamURL = async (videoID: string): Promise<string> => invoke('audio-url', videoID);
|
||||
|
||||
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
|
||||
|
||||
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
|
||||
|
||||
const watchVideoIDChanges = (cb: (id: string) => void) => {
|
||||
window.navigation.addEventListener('navigate', (event) => {
|
||||
const currentVideoID = getVideoIDFromURL(
|
||||
(event.currentTarget as Navigation).currentEntry?.url ?? '',
|
||||
);
|
||||
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
|
||||
|
||||
if (
|
||||
nextVideoID
|
||||
&& currentVideoID
|
||||
&& (firstVideo || nextVideoID !== currentVideoID)
|
||||
) {
|
||||
if (isReadyToCrossfade()) {
|
||||
crossfade(() => {
|
||||
cb(nextVideoID);
|
||||
});
|
||||
} else {
|
||||
cb(nextVideoID);
|
||||
firstVideo = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createAudioForCrossfade = (url: string) => {
|
||||
if (transitionAudio) {
|
||||
transitionAudio.unload();
|
||||
}
|
||||
|
||||
transitionAudio = new Howl({
|
||||
src: url,
|
||||
html5: true,
|
||||
volume: 0,
|
||||
});
|
||||
syncVideoWithTransitionAudio();
|
||||
};
|
||||
|
||||
const syncVideoWithTransitionAudio = () => {
|
||||
const video = document.querySelector('video')!;
|
||||
|
||||
const videoFader = new VolumeFader(video, {
|
||||
fadeScaling: config.fadeScaling,
|
||||
fadeDuration: config.fadeInDuration,
|
||||
});
|
||||
|
||||
transitionAudio.play();
|
||||
transitionAudio.seek(video.currentTime);
|
||||
|
||||
video.addEventListener('seeking', () => {
|
||||
transitionAudio.seek(video.currentTime);
|
||||
});
|
||||
|
||||
video.addEventListener('pause', () => {
|
||||
transitionAudio.pause();
|
||||
});
|
||||
|
||||
video.addEventListener('play', () => {
|
||||
transitionAudio.play();
|
||||
transitionAudio.seek(video.currentTime);
|
||||
|
||||
// Fade in
|
||||
const videoVolume = video.volume;
|
||||
video.volume = 0;
|
||||
videoFader.fadeTo(videoVolume);
|
||||
});
|
||||
|
||||
// Exit just before the end for the transition
|
||||
const transitionBeforeEnd = () => {
|
||||
if (
|
||||
video.currentTime >= video.duration - config.secondsBeforeEnd
|
||||
&& isReadyToCrossfade()
|
||||
) {
|
||||
video.removeEventListener('timeupdate', transitionBeforeEnd);
|
||||
|
||||
// Go to next video - XXX: does not support "repeat 1" mode
|
||||
document.querySelector<HTMLButtonElement>('.next-button')?.click();
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', transitionBeforeEnd);
|
||||
};
|
||||
|
||||
const onApiLoaded = () => {
|
||||
watchVideoIDChanges(async (videoID) => {
|
||||
await waitForTransition;
|
||||
const url = await getStreamURL(videoID);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
createAudioForCrossfade(url);
|
||||
});
|
||||
};
|
||||
|
||||
const crossfade = (cb: () => void) => {
|
||||
if (!isReadyToCrossfade()) {
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
|
||||
let resolveTransition: () => void;
|
||||
waitForTransition = new Promise<void>((resolve) => {
|
||||
resolveTransition = resolve;
|
||||
});
|
||||
|
||||
const video = document.querySelector('video')!;
|
||||
|
||||
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
|
||||
initialVolume: video.volume,
|
||||
fadeScaling: config.fadeScaling,
|
||||
fadeDuration: config.fadeOutDuration,
|
||||
});
|
||||
|
||||
// Fade out the music
|
||||
video.volume = 0;
|
||||
fader.fadeOut(() => {
|
||||
resolveTransition();
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
async onLoad() {
|
||||
config = await getConfig();
|
||||
},
|
||||
onPlayerApiReady() {
|
||||
onApiLoaded();
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -1,23 +1,74 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import {YoutubePlayer} from "@/types/youtube-player";
|
||||
|
||||
export type DisableAutoPlayPluginConfig = {
|
||||
enabled: boolean;
|
||||
applyOnce: boolean;
|
||||
}
|
||||
|
||||
const builder = createPluginBuilder('disable-autoplay', {
|
||||
export default createPlugin<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
config: DisableAutoPlayPluginConfig | null;
|
||||
api: YoutubePlayer | null;
|
||||
eventListener: (name: string) => void;
|
||||
timeUpdateListener: (e: Event) => void;
|
||||
},
|
||||
DisableAutoPlayPluginConfig
|
||||
>({
|
||||
name: 'Disable Autoplay',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
applyOnce: false,
|
||||
} as DisableAutoPlayPluginConfig,
|
||||
},
|
||||
menu: async ({ getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Applies only on startup',
|
||||
type: 'checkbox',
|
||||
checked: config.applyOnce,
|
||||
async click() {
|
||||
const nowConfig = await getConfig();
|
||||
setConfig({
|
||||
applyOnce: !nowConfig.applyOnce,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
renderer: {
|
||||
config: null,
|
||||
api: null,
|
||||
eventListener(name: string) {
|
||||
if (this.config?.applyOnce) {
|
||||
this.api?.removeEventListener('videodatachange', this.eventListener);
|
||||
}
|
||||
|
||||
if (name === 'dataloaded') {
|
||||
this.api?.pauseVideo();
|
||||
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', this.timeUpdateListener, { once: true });
|
||||
}
|
||||
},
|
||||
timeUpdateListener(e: Event) {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
e.target.pause();
|
||||
}
|
||||
},
|
||||
onPlayerApiReady(api) {
|
||||
this.api = api;
|
||||
|
||||
api.addEventListener('videodatachange', this.eventListener);
|
||||
},
|
||||
stop() {
|
||||
this.api?.removeEventListener('videodatachange', this.eventListener);
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
this.config = newConfig;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default builder;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Applies only on startup',
|
||||
type: 'checkbox',
|
||||
checked: config.applyOnce,
|
||||
async click() {
|
||||
const nowConfig = await getConfig();
|
||||
setConfig({
|
||||
applyOnce: !nowConfig.applyOnce,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
@ -1,42 +0,0 @@
|
||||
import builder from './index';
|
||||
|
||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
||||
|
||||
export default builder.createRenderer(({ getConfig }) => {
|
||||
let config: Awaited<ReturnType<typeof getConfig>>;
|
||||
|
||||
let apiEvent: YoutubePlayer;
|
||||
|
||||
const timeUpdateListener = (e: Event) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
e.target.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const eventListener = async (name: string) => {
|
||||
if (config.applyOnce) {
|
||||
apiEvent.removeEventListener('videodatachange', eventListener);
|
||||
}
|
||||
|
||||
if (name === 'dataloaded') {
|
||||
apiEvent.pauseVideo();
|
||||
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async onPlayerApiReady(api) {
|
||||
config = await getConfig();
|
||||
|
||||
apiEvent = api;
|
||||
|
||||
apiEvent.addEventListener('videodatachange', eventListener);
|
||||
},
|
||||
onUnload() {
|
||||
apiEvent.removeEventListener('videodatachange', eventListener);
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
};
|
||||
});
|
||||
@ -1,4 +1,6 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
import { onLoad, onUnload } from '@/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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
|
||||
@ -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:',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -4,9 +4,12 @@ import { downloadPlaylist } from './main';
|
||||
import { defaultMenuDownloadLabel, getFolder } from './main/utils';
|
||||
import { DefaultPresetList } from './types';
|
||||
|
||||
import builder from './index';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
import type { DownloaderPluginConfig } from './index';
|
||||
|
||||
export const onMenu = async ({ getConfig, setConfig }: MenuContext<DownloaderPluginConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
@ -46,4 +49,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import downloadHTML from './templates/download.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
import defaultConfig from '@/config/defaults';
|
||||
import { getSongMenu } from '@/providers/dom-elements';
|
||||
import { getSongInfo } from '@/providers/song-info-front';
|
||||
|
||||
import defaultConfig from '../../config/defaults';
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
import { getSongInfo } from '../../providers/song-info-front';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
|
||||
import type { DownloaderPluginConfig } from './index';
|
||||
|
||||
let menu: Element | null = null;
|
||||
let progress: Element | null = null;
|
||||
@ -13,70 +16,67 @@ const downloadButton = ElementFromHtml(downloadHTML);
|
||||
|
||||
let doneFirstLoad = false;
|
||||
|
||||
export default builder.createRenderer(({ invoke, on }) => {
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.contains(downloadButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(downloadButton);
|
||||
progress = document.querySelector('#ytmcustom-download');
|
||||
|
||||
if (doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||
});
|
||||
|
||||
export const onRendererLoad = ({ ipc }: RendererContext<DownloaderPluginConfig>) => {
|
||||
window.download = () => {
|
||||
let videoUrl = getSongMenu()
|
||||
// Selector of first button which is always "Start Radio"
|
||||
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
|
||||
?.getAttribute('href');
|
||||
if (videoUrl) {
|
||||
if (videoUrl.startsWith('watch?')) {
|
||||
videoUrl = defaultConfig.url + '/' + videoUrl;
|
||||
}
|
||||
|
||||
if (videoUrl.includes('?playlist=')) {
|
||||
ipc.invoke('download-playlist-request', videoUrl);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
videoUrl = getSongInfo().url || window.location.href;
|
||||
}
|
||||
|
||||
if (menu.contains(downloadButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(downloadButton);
|
||||
progress = document.querySelector('#ytmcustom-download');
|
||||
|
||||
if (doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||
});
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
window.download = () => {
|
||||
let videoUrl = getSongMenu()
|
||||
// Selector of first button which is always "Start Radio"
|
||||
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
|
||||
?.getAttribute('href');
|
||||
if (videoUrl) {
|
||||
if (videoUrl.startsWith('watch?')) {
|
||||
videoUrl = defaultConfig.url + '/' + videoUrl;
|
||||
}
|
||||
|
||||
if (videoUrl.includes('?playlist=')) {
|
||||
invoke('download-playlist-request', videoUrl);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
videoUrl = getSongInfo().url || window.location.href;
|
||||
}
|
||||
|
||||
invoke('download-song', videoUrl);
|
||||
};
|
||||
|
||||
on('downloader-feedback', (feedback: string) => {
|
||||
if (progress) {
|
||||
progress.innerHTML = feedback || 'Download';
|
||||
} else {
|
||||
console.warn('Cannot update progress');
|
||||
}
|
||||
});
|
||||
},
|
||||
onPlayerApiReady() {
|
||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
},
|
||||
ipc.invoke('download-song', videoUrl);
|
||||
};
|
||||
});
|
||||
|
||||
ipc.on('downloader-feedback', (feedback: string) => {
|
||||
if (progress) {
|
||||
progress.innerHTML = feedback || 'Download';
|
||||
} else {
|
||||
console.warn('Cannot update progress');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const onPlayerApiReady = () => {
|
||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,17 +1,50 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
const builder = createPluginBuilder('exponential-volume', {
|
||||
export default createPlugin({
|
||||
name: 'Exponential Volume',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
renderer: {
|
||||
onPlayerApiReady() {
|
||||
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
|
||||
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
|
||||
|
||||
export default builder;
|
||||
// Manipulation exponent, higher value = lower volume
|
||||
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
|
||||
// 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
|
||||
const EXPONENT = 3;
|
||||
|
||||
declare global {
|
||||
interface PluginBuilderList {
|
||||
[builder.id]: typeof builder;
|
||||
const storedOriginalVolumes = new WeakMap<HTMLMediaElement, number>();
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLMediaElement.prototype,
|
||||
'volume',
|
||||
);
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
|
||||
get(this: HTMLMediaElement) {
|
||||
const lowVolume = propertyDescriptor?.get?.call(this) as number ?? 0;
|
||||
const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
|
||||
|
||||
// The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
|
||||
// To avoid this, I'll store the unmodified volume to return it when read here.
|
||||
// This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
|
||||
// To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
|
||||
const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0;
|
||||
const storedDeviation = Math.abs(
|
||||
storedOriginalVolume - calculatedOriginalVolume,
|
||||
);
|
||||
|
||||
return storedDeviation < 0.01
|
||||
? storedOriginalVolume
|
||||
: calculatedOriginalVolume;
|
||||
},
|
||||
set(this: HTMLMediaElement, originalVolume: number) {
|
||||
const lowVolume = originalVolume ** EXPONENT;
|
||||
storedOriginalVolumes.set(this, originalVolume);
|
||||
propertyDescriptor?.set?.call(this, lowVolume);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
|
||||
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
|
||||
|
||||
import builder from './index';
|
||||
|
||||
const exponentialVolume = () => {
|
||||
// Manipulation exponent, higher value = lower volume
|
||||
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
|
||||
// 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
|
||||
const EXPONENT = 3;
|
||||
|
||||
const storedOriginalVolumes = new WeakMap<HTMLMediaElement, number>();
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLMediaElement.prototype,
|
||||
'volume',
|
||||
);
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
|
||||
get(this: HTMLMediaElement) {
|
||||
const lowVolume = propertyDescriptor?.get?.call(this) as number ?? 0;
|
||||
const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
|
||||
|
||||
// The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
|
||||
// To avoid this, I'll store the unmodified volume to return it when read here.
|
||||
// This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
|
||||
// To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
|
||||
const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0;
|
||||
const storedDeviation = Math.abs(
|
||||
storedOriginalVolume - calculatedOriginalVolume,
|
||||
);
|
||||
|
||||
return storedDeviation < 0.01
|
||||
? storedOriginalVolume
|
||||
: calculatedOriginalVolume;
|
||||
},
|
||||
set(this: HTMLMediaElement, originalVolume: number) {
|
||||
const lowVolume = originalVolume ** EXPONENT;
|
||||
storedOriginalVolumes.set(this, originalVolume);
|
||||
propertyDescriptor?.set?.call(this, lowVolume);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default builder.createRenderer(() => ({
|
||||
onPlayerApiReady() {
|
||||
exponentialVolume();
|
||||
},
|
||||
}));
|
||||
@ -1,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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ export const createPlugin = <
|
||||
BackendProperties,
|
||||
PreloadProperties,
|
||||
RendererProperties,
|
||||
Config extends PluginConfig,
|
||||
Config extends PluginConfig = PluginConfig,
|
||||
>(
|
||||
def: PluginDef<
|
||||
BackendProperties,
|
||||
|
||||
Reference in New Issue
Block a user