feat: migration to TypeScript part 2

Co-authored-by: Su-Yong <simssy2205@gmail.com>
This commit is contained in:
JellyBrick
2023-09-03 06:37:47 +09:00
parent 82bcadcd64
commit d30755e5fa
40 changed files with 523 additions and 296 deletions

View File

@ -122,6 +122,9 @@ const defaultConfig = {
'captions-selector': { 'captions-selector': {
enabled: false, enabled: false,
disableCaptions: false, disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
disabledCaptions: false,
}, },
'skip-silences': { 'skip-silences': {
onlySkipBeginning: false, onlySkipBeginning: false,

View File

@ -70,13 +70,14 @@ interface PluginConfigOptions {
* setupMyPlugin(win, config); * setupMyPlugin(win, config);
* }; * };
*/ */
type ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T]; export type ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T];
type ValueOf<T> = T[keyof T]; type ValueOf<T> = T[keyof T];
type Mode<T, Mode extends 'r' | 'm'> = Mode extends 'r' ? Promise<T> : T;
export class PluginConfig<T extends OneOfDefaultConfigKey> { export class PluginConfig<T extends OneOfDefaultConfigKey> {
private name: string; private readonly name: string;
private config: ConfigType<T>; private readonly config: ConfigType<T>;
private defaultConfig: ConfigType<T>; private readonly defaultConfig: ConfigType<T>;
private enableFront: boolean; private readonly enableFront: boolean;
private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {}; private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {};
private allSubscribers: ((config: ConfigType<T>) => void)[] = []; private allSubscribers: ((config: ConfigType<T>) => void)[] = [];
@ -102,7 +103,7 @@ export class PluginConfig<T extends OneOfDefaultConfigKey> {
activePlugins[name] = this; activePlugins[name] = this;
} }
async get(key: keyof ConfigType<T>): Promise<ValueOf<ConfigType<T>>> { get<Key extends keyof ConfigType<T> = keyof ConfigType<T>>(key: Key): ConfigType<T>[Key] {
return this.config[key]; return this.config[key];
} }
@ -112,11 +113,11 @@ export class PluginConfig<T extends OneOfDefaultConfigKey> {
this.#save(); this.#save();
} }
getAll() { getAll(): ConfigType<T> {
return { ...this.config }; return { ...this.config };
} }
setAll(options: ConfigType<T>) { setAll(options: Partial<ConfigType<T>>) {
if (!options || typeof options !== 'object') { if (!options || typeof options !== 'object') {
throw new Error('Options must be an object.'); throw new Error('Options must be an object.');
} }
@ -124,7 +125,7 @@ export class PluginConfig<T extends OneOfDefaultConfigKey> {
let changed = false; let changed = false;
for (const [key, value] of Object.entries(options) as Entries<typeof options>) { for (const [key, value] of Object.entries(options) as Entries<typeof options>) {
if (this.config[key] !== value) { if (this.config[key] !== value) {
this.config[key] = value; if (value !== undefined) this.config[key] = value;
this.#onChange(key, false); this.#onChange(key, false);
changed = true; changed = true;
} }

View File

@ -24,8 +24,8 @@ declare module 'custom-electron-prompt' {
cancel?: string; cancel?: string;
}; };
alwaysOnTop?: boolean; alwaysOnTop?: boolean;
value?: string; value?: unknown;
type?: 'input' | 'select' | 'counter'; type?: 'input' | 'select' | 'counter' | 'multiInput';
selectOptions?: Record<string, string>; selectOptions?: Record<string, string>;
keybindOptions?: PromptKeybindOptions[]; keybindOptions?: PromptKeybindOptions[];
counterOptions?: PromptCounterOptions; counterOptions?: PromptCounterOptions;
@ -37,7 +37,13 @@ declare module 'custom-electron-prompt' {
frame?: boolean; frame?: boolean;
customScript?: string; customScript?: string;
enableRemoteModule?: boolean; enableRemoteModule?: boolean;
inputAttrs: Partial<HTMLInputElement>; inputAttrs?: Partial<HTMLInputElement>;
multiInputOptions?: {
label: string;
value: unknown;
inputAttrs?: Partial<HTMLInputElement>;
selectOptions?: Record<string, string>;
}[];
} }
const prompt: (options?: PromptOptions, parent?: BrowserWindow) => Promise<string | null>; const prompt: (options?: PromptOptions, parent?: BrowserWindow) => Promise<string | null>;

88
navigation.d.ts vendored Normal file
View File

@ -0,0 +1,88 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface NavigationOptions {
info: any;
}
interface NavigationHistoryEntry extends EventTarget {
readonly url?: string;
readonly key: string;
readonly id: string;
readonly index: number;
readonly sameDocument: boolean;
getState(): any;
ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null;
}
interface NavigationTransition {
readonly navigationType: NavigationType;
readonly from: NavigationHistoryEntry;
readonly finished: Promise<undefined>;
}
interface NavigationResult {
committed: Promise<NavigationHistoryEntry>;
finished: Promise<NavigationHistoryEntry>;
}
interface NavigationNavigateOptions extends NavigationOptions {
state: any;
history?: NavigationHistoryBehavior;
}
interface NavigationReloadOptions extends NavigationOptions {
state: any;
}
interface NavigationUpdateCurrentEntryOptions {
state: any;
}
interface NavigationEventsMap {
currententrychange: NavigateEvent;
navigate: NavigateEvent;
navigateerror: NavigateEvent;
navigatesuccess: NavigateEvent;
}
interface Navigation extends EventTarget {
entries(): Array<NavigationHistoryEntry>;
readonly currentEntry?: NavigationHistoryEntry;
updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): undefined;
readonly transition?: NavigationTransition;
readonly canGoBack: boolean;
readonly canGoForward: boolean;
navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
reload(options?: NavigationReloadOptions): NavigationResult;
traverseTo(key: string, options?: NavigationOptions): NavigationResult;
back(options?: NavigationOptions): NavigationResult;
forward(options?: NavigationOptions): NavigationResult;
onnavigate: ((this: Navigation, ev: Event) => any) | null;
onnavigatesuccess: ((this: Navigation, ev: Event) => any) | null;
onnavigateerror: ((this: Navigation, ev: Event) => any) | null;
oncurrententrychange: ((this: Navigation, ev: Event) => any) | null;
addEventListener<K extends keyof NavigationEventsMap>(name: K, listener: (event: NavigationEventsMap[K]) => void);
}
declare class NavigateEvent extends Event {
canIntercept: boolean;
destination: NavigationHistoryEntry;
downloadRequest: string | null;
formData: FormData;
hashChange: boolean;
info: Record<string, string>;
navigationType: 'push' | 'reload' | 'replace' | 'traverse';
signal: AbortSignal;
userInitiated: boolean;
intercept(options?: Record<string, unknown>): void;
scroll(): void;
}
type NavigationHistoryBehavior = 'auto' | 'push' | 'replace';
declare const Navigation: {
prototype: Navigation;
new(): Navigation;
};

7
package-lock.json generated
View File

@ -44,6 +44,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "1.37.1", "@playwright/test": "1.37.1",
"@total-typescript/ts-reset": "0.5.1", "@total-typescript/ts-reset": "0.5.1",
"@types/howler": "^2.2.8",
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0", "@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0", "auto-changelog": "2.4.0",
@ -1200,6 +1201,12 @@
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.12.tgz", "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.12.tgz",
"integrity": "sha512-P20p/YBrqUBmzD6KhIQ8EiY4/RRzlekL4eCvfQnulFPfjmiGxKIoyCeI7qam5I7oKH3P8EU4ptEi0EfyGoLysw==" "integrity": "sha512-P20p/YBrqUBmzD6KhIQ8EiY4/RRzlekL4eCvfQnulFPfjmiGxKIoyCeI7qam5I7oKH3P8EU4ptEi0EfyGoLysw=="
}, },
"node_modules/@types/howler": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.8.tgz",
"integrity": "sha512-7OK+cGHTWIDCOvBlEc61Lzj2tJhCpmeqiqdeNbZvTxLHluBMF6xz/2wjoQkK1M8mJIStp40OdPnkp8xIvwwsuw==",
"dev": true
},
"node_modules/@types/http-cache-semantics": { "node_modules/@types/http-cache-semantics": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",

View File

@ -149,6 +149,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "1.37.1", "@playwright/test": "1.37.1",
"@total-typescript/ts-reset": "0.5.1", "@total-typescript/ts-reset": "0.5.1",
"@types/howler": "^2.2.8",
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0", "@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0", "auto-changelog": "2.4.0",

View File

@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { PluginConfig } from '../../config/dynamic'; import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('adblocker', { enableFront: true }); const config = new PluginConfig('adblocker', { enableFront: true });

View File

@ -1,15 +1,13 @@
import config from './config'; import config, { blockers } from './config';
export default async () => {
const blockerConfig = await config.get('blocker');
export default () => {
return [ return [
{ {
label: 'Blocker', label: 'Blocker',
submenu: Object.values(config.blockers).map((blocker) => ({ submenu: Object.values(blockers).map((blocker: string) => ({
label: blocker, label: blocker,
type: 'radio', type: 'radio',
checked: (blockerConfig || config.blockers.WithBlocklists) === blocker, checked: (config.get('blocker') || blockers.WithBlocklists) === blocker,
click() { click() {
config.set('blocker', blocker); config.set('blocker', blocker);
}, },

View File

@ -1,19 +0,0 @@
const applyCompressor = (e) => {
const { audioContext } = e.detail;
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
e.detail.audioSource.connect(compressor);
compressor.connect(audioContext.destination);
};
module.exports = () =>
document.addEventListener('audioCanPlay', applyCompressor, {
once: true, // Only create the audio compressor once, not on each video
passive: true,
});

View File

@ -0,0 +1,17 @@
export default () =>
document.addEventListener('audioCanPlay', (e) => {
const { audioContext } = e.detail;
const compressor = audioContext.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.ratio.value = 12;
compressor.knee.value = 40;
compressor.attack.value = 0;
compressor.release.value = 0.25;
e.detail.audioSource.connect(compressor);
compressor.connect(audioContext.destination);
}, {
once: true, // Only create the audio compressor once, not on each video
passive: true,
});

View File

@ -1,7 +0,0 @@
const path = require('node:path');
const { injectCSS } = require('../utils');
module.exports = (win) => {
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
};

View File

@ -0,0 +1,9 @@
import path from 'node:path';
import { BrowserWindow } from 'electron';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
};

View File

@ -1,4 +1,4 @@
module.exports = () => { export default () => {
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript // See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
require('simple-youtube-age-restriction-bypass/dist/Simple-YouTube-Age-Restriction-Bypass.user.js'); require('simple-youtube-age-restriction-bypass/dist/Simple-YouTube-Age-Restriction-Bypass.user.js');
}; };

View File

@ -1,19 +0,0 @@
const { ipcMain } = require('electron');
const prompt = require('custom-electron-prompt');
const promptOptions = require('../../providers/prompt-options');
module.exports = (win) => {
ipcMain.handle('captionsSelector', async (_, captionLabels, currentIndex) => await prompt(
{
title: 'Choose Caption',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
type: 'select',
value: currentIndex,
selectOptions: captionLabels,
resizable: true,
...promptOptions(),
},
win,
));
};

View File

@ -0,0 +1,19 @@
import { BrowserWindow, ipcMain } from 'electron';
import prompt from 'custom-electron-prompt';
import promptOptions from '../../providers/prompt-options';
export default (win: BrowserWindow) => {
ipcMain.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,
));
};

View File

@ -1,4 +0,0 @@
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('captions-selector', { enableFront: true });
module.exports = { ...config };

View File

@ -0,0 +1,4 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('captions-selector', { enableFront: true });
export default { ...config } as PluginConfig<'captions-selector'>;

View File

@ -1,20 +1,37 @@
const { ipcRenderer } = require('electron'); /* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
const configProvider = require('./config'); import { ipcRenderer } from 'electron';
const { ElementFromFile, templatePath } = require('../utils'); import configProvider from './config';
let config; import { ElementFromFile, templatePath } from '../utils';
import { YoutubePlayer } from '../../types/youtube-player';
import { ConfigType } from '../../config/dynamic';
function $(selector) { interface LanguageOptions {
return document.querySelector(selector); 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;
} }
let config: ConfigType<'captions-selector'>;
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
const captionsSettingsButton = ElementFromFile( const captionsSettingsButton = ElementFromFile(
templatePath(__dirname, 'captions-settings-template.html'), templatePath(__dirname, 'captions-settings-template.html'),
); );
module.exports = async () => { export default async () => {
// RENDERER
config = await configProvider.getAll(); config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => { configProvider.subscribeAll((newConfig) => {
@ -23,12 +40,12 @@ module.exports = async () => {
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true }); document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
}; };
function setup(api) { function setup(api: YoutubePlayer) {
$('.right-controls-buttons').append(captionsSettingsButton); $('.right-controls-buttons').append(captionsSettingsButton);
let captionTrackList = api.getOption('captions', 'tracklist'); let captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
$('video').addEventListener('srcChanged', async () => { $('video').addEventListener('srcChanged', () => {
if (config.disableCaptions) { if (config.disableCaptions) {
setTimeout(() => api.unloadModule('captions'), 100); setTimeout(() => api.unloadModule('captions'), 100);
captionsSettingsButton.style.display = 'none'; captionsSettingsButton.style.display = 'none';
@ -37,8 +54,8 @@ function setup(api) {
api.loadModule('captions'); api.loadModule('captions');
setTimeout(async () => { setTimeout(() => {
captionTrackList = api.getOption('captions', 'tracklist'); captionTrackList = api.getOption('captions', 'tracklist') ?? [];
if (config.autoload && config.lastCaptionsCode) { if (config.autoload && config.lastCaptionsCode) {
api.setOption('captions', 'track', { api.setOption('captions', 'track', {
@ -54,9 +71,9 @@ function setup(api) {
captionsSettingsButton.addEventListener('click', async () => { captionsSettingsButton.addEventListener('click', async () => {
if (captionTrackList?.length) { if (captionTrackList?.length) {
const currentCaptionTrack = api.getOption('captions', 'track'); const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
let currentIndex = currentCaptionTrack let currentIndex = currentCaptionTrack
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)) ? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!)
: null; : null;
const captionLabels = [ const captionLabels = [
@ -64,7 +81,7 @@ function setup(api) {
'None', 'None',
]; ];
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex); currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
if (currentIndex === null) { if (currentIndex === null) {
return; return;
} }

View File

@ -1,6 +1,8 @@
const config = require('./config'); import config from './config';
module.exports = () => [ import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => [
{ {
label: 'Automatically select last used caption', label: 'Automatically select last used caption',
type: 'checkbox', type: 'checkbox',

View File

@ -1,10 +1,10 @@
module.exports = () => { export default () => {
const compactSidebar = document.querySelector('#mini-guide'); const compactSidebar = document.querySelector('#mini-guide');
const isCompactSidebarDisabled const isCompactSidebarDisabled
= compactSidebar === null = compactSidebar === null
|| window.getComputedStyle(compactSidebar).display === 'none'; || window.getComputedStyle(compactSidebar).display === 'none';
if (isCompactSidebarDisabled) { if (isCompactSidebarDisabled) {
document.querySelector('#button').click(); (document.querySelector('#button') as HTMLButtonElement)?.click();
} }
}; };

View File

@ -1,15 +0,0 @@
const { ipcMain } = require('electron');
const { Innertube } = require('youtubei.js');
require('./config');
module.exports = async () => {
const yt = await Innertube.create();
ipcMain.handle('audio-url', async (_, videoID) => {
const info = await yt.getBasicInfo(videoID);
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
return url;
});
};

15
plugins/crossfade/back.ts Normal file
View File

@ -0,0 +1,15 @@
import { ipcMain } from 'electron';
import { Innertube } from 'youtubei.js';
import config from './config';
export default async () => {
const yt = await Innertube.create();
ipcMain.handle('audio-url', async (_, videoID: string) => {
const info = await yt.getBasicInfo(videoID);
const url = info.streaming_data?.formats[0].decipher(yt.session.player);
return url;
});
};

View File

@ -1,4 +0,0 @@
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('crossfade', { enableFront: true });
module.exports = { ...config };

View File

@ -0,0 +1,4 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('crossfade', { enableFront: true });
export default { ...config } as PluginConfig<'crossfade'>;

View File

@ -18,7 +18,7 @@
'use strict'; 'use strict';
// Internal utility: check if value is a valid volume level and throw if not // Internal utility: check if value is a valid volume level and throw if not
const validateVolumeLevel = (value) => { const validateVolumeLevel = (value: number) => {
// Number between 0 and 1? // Number between 0 and 1?
if (!Number.isNaN(value) && value >= 0 && value <= 1) { if (!Number.isNaN(value) && value >= 0 && value <= 1) {
// Yup, that's fine // Yup, that's fine
@ -29,8 +29,51 @@ const validateVolumeLevel = (value) => {
} }
}; };
type VolumeLogger = <Params extends unknown[]>(message: string, ...args: Params) => void;
interface VolumeFaderOptions {
/**
* logging `function(stuff, …)` for execution information (default: no logging)
*/
logger?: VolumeLogger;
/**
* either 'linear', 'logarithmic' or a positive number in dB (default: logarithmic)
*/
fadeScaling?: string | number;
/**
* media volume 01 to apply during setup (volume not touched by default)
*/
initialVolume?: number;
/**
* time in milliseconds to complete a fade (default: 1000 ms)
*/
fadeDuration?: number;
}
interface VolumeFade {
volume: {
start: number;
end: number;
};
time: {
start: number;
end: number;
};
callback?: () => void;
}
// Main class // Main class
class VolumeFader { export class VolumeFader {
private media: HTMLMediaElement;
private logger: VolumeLogger | false;
private scale: {
internalToVolume: (level: number) => number;
volumeToInternal: (level: number) => number;
};
private fadeDuration: number = 1000;
private active: boolean = false;
private fade: VolumeFade | undefined;
/** /**
* VolumeFader Constructor * VolumeFader Constructor
* *
@ -38,13 +81,8 @@ class VolumeFader {
* @param options {Object} - an object with optional settings * @param options {Object} - an object with optional settings
* @throws {TypeError} if options.initialVolume or options.fadeDuration are invalid * @throws {TypeError} if options.initialVolume or options.fadeDuration are invalid
* *
* options:
* .logger: {Function} logging `function(stuff, …)` for execution information (default: no logging)
* .fadeScaling: {Mixed} either 'linear', 'logarithmic' or a positive number in dB (default: logarithmic)
* .initialVolume: {Number} media volume 01 to apply during setup (volume not touched by default)
* .fadeDuration: {Number} time in milliseconds to complete a fade (default: 1000 ms)
*/ */
constructor(media, options) { constructor(media: HTMLMediaElement, options: VolumeFaderOptions) {
// Passed media element of correct type? // Passed media element of correct type?
if (media instanceof HTMLMediaElement) { if (media instanceof HTMLMediaElement) {
// Save reference to media element // Save reference to media element
@ -70,8 +108,8 @@ class VolumeFader {
if (options.fadeScaling === 'linear') { if (options.fadeScaling === 'linear') {
// Pass levels unchanged // Pass levels unchanged
this.scale = { this.scale = {
internalToVolume: (level) => level, internalToVolume: (level: number) => level,
volumeToInternal: (level) => level, volumeToInternal: (level: number) => level,
}; };
// Log setting // Log setting
@ -79,7 +117,7 @@ class VolumeFader {
} }
// No linear, but logarithmic fading… // No linear, but logarithmic fading…
else { else {
let dynamicRange; let dynamicRange: number;
// Default dynamic range? // Default dynamic range?
if ( if (
@ -91,7 +129,8 @@ class VolumeFader {
} }
// Custom dynamic range? // Custom dynamic range?
else if ( else if (
!Number.isNaN(options.fadeScaling) typeof options.fadeScaling === 'number'
&& !Number.isNaN(options.fadeScaling)
&& options.fadeScaling > 0 && options.fadeScaling > 0
) { ) {
// Turn amplitude dB into a multiple of 10 power dB // Turn amplitude dB into a multiple of 10 power dB
@ -107,9 +146,9 @@ class VolumeFader {
// Use exponential/logarithmic scaler for expansion/compression // Use exponential/logarithmic scaler for expansion/compression
this.scale = { this.scale = {
internalToVolume: (level) => internalToVolume: (level: number) =>
this.exponentialScaler(level, dynamicRange), this.exponentialScaler(level, dynamicRange),
volumeToInternal: (level) => volumeToInternal: (level: number) =>
this.logarithmicScaler(level, dynamicRange), this.logarithmicScaler(level, dynamicRange),
}; };
@ -193,7 +232,7 @@ class VolumeFader {
* @throws {TypeError} if fadeDuration is not a number greater than zero * @throws {TypeError} if fadeDuration is not a number greater than zero
* @return {Object} VolumeFader instance for chaining * @return {Object} VolumeFader instance for chaining
*/ */
setFadeDuration(fadeDuration) { setFadeDuration(fadeDuration: number) {
// If duration is a valid number > 0… // If duration is a valid number > 0…
if (!Number.isNaN(fadeDuration) && fadeDuration > 0) { if (!Number.isNaN(fadeDuration) && fadeDuration > 0) {
// Set fade duration // Set fade duration
@ -219,7 +258,7 @@ class VolumeFader {
* @throws {TypeError} if targetVolume is not in the range 01 * @throws {TypeError} if targetVolume is not in the range 01
* @return {Object} VolumeFader instance for chaining * @return {Object} VolumeFader instance for chaining
*/ */
fadeTo(targetVolume, callback) { fadeTo(targetVolume: number, callback?: () => void) {
// Validate volume and throw if invalid // Validate volume and throw if invalid
validateVolumeLevel(targetVolume); validateVolumeLevel(targetVolume);
@ -250,11 +289,11 @@ class VolumeFader {
} }
// Convenience shorthand methods for common fades // Convenience shorthand methods for common fades
fadeIn(callback) { fadeIn(callback: () => void) {
this.fadeTo(1, callback); this.fadeTo(1, callback);
} }
fadeOut(callback) { fadeOut(callback: () => void) {
this.fadeTo(0, callback); this.fadeTo(0, callback);
} }
@ -313,7 +352,7 @@ class VolumeFader {
* @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0) * @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0)
* @return {Number} - expanded level (float, 01) * @return {Number} - expanded level (float, 01)
*/ */
exponentialScaler(input, dynamicRange) { exponentialScaler(input: number, dynamicRange: number) {
// Special case: make zero (or any falsy input) return zero // Special case: make zero (or any falsy input) return zero
if (input === 0) { if (input === 0) {
// Since the dynamic range is limited, // Since the dynamic range is limited,
@ -336,7 +375,7 @@ class VolumeFader {
* @param {Number} dynamicRange - coerced input range, in multiples of 10 dB (float, 0) * @param {Number} dynamicRange - coerced input range, in multiples of 10 dB (float, 0)
* @return {Number} - compressed level (float, 01) * @return {Number} - compressed level (float, 01)
*/ */
logarithmicScaler(input, dynamicRange) { logarithmicScaler(input: number, dynamicRange: number) {
// Special case: make zero (or any falsy input) return zero // Special case: make zero (or any falsy input) return zero
if (input === 0) { if (input === 0) {
// Logarithm of zero would be -∞, which would map to zero anyway // Logarithm of zero would be -∞, which would map to zero anyway
@ -351,6 +390,6 @@ class VolumeFader {
} }
} }
module.exports = { export default {
VolumeFader VolumeFader
}; };

View File

@ -1,38 +1,39 @@
const { ipcRenderer } = require('electron'); /* eslint-disable @typescript-eslint/await-thenable */
const { Howl } = require('howler'); /* renderer */
import { ipcRenderer } from 'electron';
import { Howl } from 'howler';
// Extracted from https://github.com/bitfasching/VolumeFader // Extracted from https://github.com/bitfasching/VolumeFader
const { VolumeFader } = require('./fader'); import { VolumeFader } from './fader';
let transitionAudio; // Howler audio used to fade out the current music import configProvider from './config';
import defaultConfigs from '../../config/defaults';
import { ConfigType } from '../../config/dynamic';
let transitionAudio: Howl; // Howler audio used to fade out the current music
let firstVideo = true; let firstVideo = true;
let waitForTransition; let waitForTransition: Promise<unknown>;
/** const defaultConfig = defaultConfigs.plugins.crossfade;
* @type {PluginConfig}
*/
const configProvider = require('./config');
const defaultConfig = require('../../config/defaults').plugins.crossfade; let config: ConfigType<'crossfade'>;
let config; const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(config[key]) || (defaultConfig[key] as number);
const configGetNumber = (key) => Number(config[key]) || defaultConfig[key]; const getStreamURL = async (videoID: string) => ipcRenderer.invoke('audio-url', videoID) as Promise<string>;
const getStreamURL = async (videoID) => { const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
return await ipcRenderer.invoke('audio-url', videoID);
};
const getVideoIDFromURL = (url) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded'; const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
const watchVideoIDChanges = (cb) => { const watchVideoIDChanges = (cb: (id: string) => void) => {
window.navigation.addEventListener('navigate', (event) => { window.navigation.addEventListener('navigate', (event) => {
const currentVideoID = getVideoIDFromURL( const currentVideoID = getVideoIDFromURL(
event.currentTarget.currentEntry.url, (event.currentTarget as Navigation).currentEntry?.url ?? '',
); );
const nextVideoID = getVideoIDFromURL(event.destination.url); const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
if ( if (
nextVideoID nextVideoID
@ -51,7 +52,7 @@ const watchVideoIDChanges = (cb) => {
}); });
}; };
const createAudioForCrossfade = async (url) => { const createAudioForCrossfade = (url: string) => {
if (transitionAudio) { if (transitionAudio) {
transitionAudio.unload(); transitionAudio.unload();
} }
@ -61,19 +62,19 @@ const createAudioForCrossfade = async (url) => {
html5: true, html5: true,
volume: 0, volume: 0,
}); });
await syncVideoWithTransitionAudio(); syncVideoWithTransitionAudio();
}; };
const syncVideoWithTransitionAudio = async () => { const syncVideoWithTransitionAudio = () => {
const video = document.querySelector('video'); const video = document.querySelector('video')!;
const videoFader = new VolumeFader(video, { const videoFader = new VolumeFader(video, {
fadeScaling: configGetNumber('fadeScaling'), fadeScaling: configGetNumber('fadeScaling'),
fadeDuration: configGetNumber('fadeInDuration'), fadeDuration: configGetNumber('fadeInDuration'),
}); });
await transitionAudio.play(); transitionAudio.play();
await transitionAudio.seek(video.currentTime); transitionAudio.seek(video.currentTime);
video.addEventListener('seeking', () => { video.addEventListener('seeking', () => {
transitionAudio.seek(video.currentTime); transitionAudio.seek(video.currentTime);
@ -83,9 +84,9 @@ const syncVideoWithTransitionAudio = async () => {
transitionAudio.pause(); transitionAudio.pause();
}); });
video.addEventListener('play', async () => { video.addEventListener('play', () => {
await transitionAudio.play(); transitionAudio.play();
await transitionAudio.seek(video.currentTime); transitionAudio.seek(video.currentTime);
// Fade in // Fade in
const videoVolume = video.volume; const videoVolume = video.volume;
@ -102,7 +103,7 @@ const syncVideoWithTransitionAudio = async () => {
video.removeEventListener('timeupdate', transitionBeforeEnd); video.removeEventListener('timeupdate', transitionBeforeEnd);
// Go to next video - XXX: does not support "repeat 1" mode // Go to next video - XXX: does not support "repeat 1" mode
document.querySelector('.next-button').click(); (document.querySelector('.next-button') as HTMLButtonElement).click();
} }
}; };
@ -121,18 +122,18 @@ const onApiLoaded = () => {
}); });
}; };
const crossfade = async (cb) => { const crossfade = (cb: () => void) => {
if (!isReadyToCrossfade()) { if (!isReadyToCrossfade()) {
cb(); cb();
return; return;
} }
let resolveTransition; let resolveTransition: () => void;
waitForTransition = new Promise((resolve) => { waitForTransition = new Promise<void>((resolve) => {
resolveTransition = resolve; resolveTransition = resolve;
}); });
const video = document.querySelector('video'); const video = document.querySelector('video')!;
const fader = new VolumeFader(transitionAudio._sounds[0]._node, { const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume, initialVolume: video.volume,
@ -148,7 +149,7 @@ const crossfade = async (cb) => {
}); });
}; };
module.exports = async () => { export default async () => {
config = await configProvider.getAll(); config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => { configProvider.subscribeAll((newConfig) => {

View File

@ -1,11 +1,16 @@
const prompt = require('custom-electron-prompt'); import prompt from 'custom-electron-prompt';
const config = require('./config'); import { BrowserWindow } from 'electron';
const promptOptions = require('../../providers/prompt-options'); import config from './config';
const defaultOptions = require('../../config/defaults').plugins.crossfade;
module.exports = (win) => [ import promptOptions from '../../providers/prompt-options';
import configOptions from '../../config/defaults';
import { ConfigType } from '../../config/dynamic';
const defaultOptions = configOptions.plugins.crossfade;
export default (win: BrowserWindow) => [
{ {
label: 'Advanced', label: 'Advanced',
async click() { async click() {
@ -17,7 +22,7 @@ module.exports = (win) => [
}, },
]; ];
async function promptCrossfadeValues(win, options) { async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'crossfade'>): Promise<Partial<ConfigType<'crossfade'>> | undefined> {
const res = await prompt( const res = await prompt(
{ {
title: 'Crossfade Options', title: 'Crossfade Options',
@ -29,8 +34,8 @@ async function promptCrossfadeValues(win, options) {
inputAttrs: { inputAttrs: {
type: 'number', type: 'number',
required: true, required: true,
min: 0, min: '0',
step: 100, step: '100',
}, },
}, },
{ {
@ -39,8 +44,8 @@ async function promptCrossfadeValues(win, options) {
inputAttrs: { inputAttrs: {
type: 'number', type: 'number',
required: true, required: true,
min: 0, min: '0',
step: 100, step: '100',
}, },
}, },
{ {
@ -50,7 +55,7 @@ async function promptCrossfadeValues(win, options) {
inputAttrs: { inputAttrs: {
type: 'number', type: 'number',
required: true, required: true,
min: 0, min: '0',
}, },
}, },
{ {

View File

@ -1,14 +0,0 @@
module.exports = () => {
document.addEventListener('apiLoaded', (apiEvent) => {
apiEvent.detail.addEventListener('videodatachange', (name) => {
if (name === 'dataloaded') {
apiEvent.detail.pauseVideo();
document.querySelector('video').addEventListener('timeupdate', (e) => {
e.target.pause();
});
} else {
document.querySelector('video').ontimeupdate = null;
}
});
}, { once: true, passive: true });
};

View File

@ -0,0 +1,14 @@
export default () => {
document.addEventListener('apiLoaded', (apiEvent) => {
apiEvent.detail.addEventListener('videodatachange', (name: string) => {
if (name === 'dataloaded') {
apiEvent.detail.pauseVideo();
(document.querySelector('video') as HTMLVideoElement)?.addEventListener('timeupdate', (e) => {
(e.target as HTMLVideoElement)?.pause();
});
} else {
(document.querySelector('video') as HTMLVideoElement).ontimeupdate = null;
}
});
}, { once: true, passive: true });
};

View File

@ -1,36 +1,35 @@
'use strict'; import { app, dialog } from 'electron';
const { dialog, app } = require('electron'); import Discord from '@xhayper/discord-rpc';
const Discord = require('@xhayper/discord-rpc'); import { dev } from 'electron-is';
const { dev } = require('electron-is');
const registerCallback = require('../../providers/song-info'); import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import registerCallback from '../../providers/song-info';
import pluginConfig from '../../config';
// Application ID registered by @Zo-Bro-23 // Application ID registered by @Zo-Bro-23
const clientId = '1043858434585526382'; const clientId = '1043858434585526382';
/** export interface Info {
* @typedef {Object} Info rpc: import('@xhayper/discord-rpc').Client;
* @property {import('@xhayper/discord-rpc').Client} rpc ready: boolean;
* @property {boolean} ready autoReconnect: boolean;
* @property {boolean} autoReconnect lastSongInfo?: import('../../providers/song-info').SongInfo;
* @property {import('../../providers/song-info').SongInfo} lastSongInfo }
*/
/** const info: Info = {
* @type {Info}
*/
const info = {
rpc: new Discord.Client({ rpc: new Discord.Client({
clientId, clientId,
}), }),
ready: false, ready: false,
autoReconnect: true, autoReconnect: true,
lastSongInfo: null, lastSongInfo: undefined,
}; };
/** /**
* @type {(() => void)[]} * @type {(() => void)[]}
*/ */
const refreshCallbacks = []; const refreshCallbacks: (() => void)[] = [];
const resetInfo = () => { const resetInfo = () => {
info.ready = false; info.ready = false;
@ -85,8 +84,8 @@ const connectRecursive = () => {
connectTimeout().catch(connectRecursive); connectTimeout().catch(connectRecursive);
}; };
let window; let window: Electron.BrowserWindow;
const connect = (showError = false) => { export const connect = (showError = false) => {
if (info.rpc.isConnected) { if (info.rpc.isConnected) {
if (dev()) { if (dev()) {
console.log('Attempted to connect with active connection'); console.log('Attempted to connect with active connection');
@ -98,7 +97,7 @@ const connect = (showError = false) => {
info.ready = false; info.ready = false;
// Startup the rpc client // Startup the rpc client
info.rpc.login({ clientId }).catch((error) => { info.rpc.login().catch((error: Error) => {
resetInfo(); resetInfo();
if (dev()) { if (dev()) {
console.error(error); console.error(error);
@ -116,14 +115,17 @@ const connect = (showError = false) => {
}); });
}; };
let clearActivity; let clearActivity: NodeJS.Timeout | undefined;
/** let updateActivity: import('../../providers/song-info').SongInfoCallback;
* @type {import('../../providers/song-info').songInfoCallback}
*/
let updateActivity;
module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTime, listenAlong, hideDurationLeft }) => { const DiscordOptionsObj = pluginConfig.get('plugins.discord');
info.autoReconnect = autoReconnect; type DiscordOptions = typeof DiscordOptionsObj;
export default (
win: Electron.BrowserWindow,
options: DiscordOptions,
) => {
info.autoReconnect = options.autoReconnect;
window = win; window = win;
// We get multiple events // We get multiple events
@ -146,7 +148,7 @@ module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTim
} }
// Clear directly if timeout is 0 // Clear directly if timeout is 0
if (songInfo.isPaused && activityTimoutEnabled && activityTimoutTime === 0) { if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) {
info.rpc.user?.clearActivity().catch(console.error); info.rpc.user?.clearActivity().catch(console.error);
return; return;
} }
@ -154,12 +156,12 @@ module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTim
// Song information changed, so lets update the rich presence // Song information changed, so lets update the rich presence
// @see https://discord.com/developers/docs/topics/gateway#activity-object // @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 // not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
const activityInfo = { const activityInfo: SetActivity = {
details: songInfo.title, details: songInfo.title,
state: songInfo.artist, state: songInfo.artist,
largeImageKey: songInfo.imageSrc, largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album, largeImageText: songInfo.album ?? '',
buttons: listenAlong ? [ buttons: options.listenAlong ? [
{ label: 'Listen Along', url: songInfo.url }, { label: 'Listen Along', url: songInfo.url },
] : undefined, ] : undefined,
}; };
@ -169,10 +171,10 @@ module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTim
activityInfo.smallImageKey = 'paused'; activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused'; activityInfo.smallImageText = 'Paused';
// Set start the timer so the activity gets cleared after a while if enabled // Set start the timer so the activity gets cleared after a while if enabled
if (activityTimoutEnabled) { if (options.activityTimoutEnabled) {
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), activityTimoutTime ?? 10_000); clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000);
} }
} else if (!hideDurationLeft) { } else if (!options.hideDurationLeft) {
// Add the start and end time of the song // Add the start and end time of the song
const songStartTime = Date.now() - (songInfo.elapsedSeconds * 1000); const songStartTime = Date.now() - (songInfo.elapsedSeconds * 1000);
activityInfo.startTimestamp = songStartTime; activityInfo.startTimestamp = songStartTime;
@ -188,10 +190,10 @@ module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTim
registerCallback(updateActivity); registerCallback(updateActivity);
connect(); connect();
}); });
app.on('window-all-closed', module.exports.clear); app.on('window-all-closed', clear);
}; };
module.exports.clear = () => { export const clear = () => {
if (info.rpc) { if (info.rpc) {
info.rpc.user?.clearActivity(); info.rpc.user?.clearActivity();
} }
@ -199,6 +201,5 @@ module.exports.clear = () => {
clearTimeout(clearActivity); clearTimeout(clearActivity);
}; };
module.exports.connect = connect; export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
module.exports.registerRefresh = (cb) => refreshCallbacks.push(cb); export const isConnected = () => info.rpc !== null;
module.exports.isConnected = () => info.rpc !== null;

View File

@ -1,16 +1,22 @@
const prompt = require('custom-electron-prompt'); import prompt from 'custom-electron-prompt';
const { clear, connect, registerRefresh, isConnected } = require('./back'); import { Electron } from 'playwright';
const { setMenuOptions } = require('../../config/plugins'); import { clear, connect, isConnected, registerRefresh } from './back';
const promptOptions = require('../../providers/prompt-options');
const { singleton } = require('../../providers/decorators');
const registerRefreshOnce = singleton((refreshMenu) => { import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options';
import { singleton } from '../../providers/decorators';
import config from '../../config';
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
registerRefresh(refreshMenu); registerRefresh(refreshMenu);
}); });
module.exports = (win, options, refreshMenu) => { const DiscordOptionsObj = config.get('plugins.discord');
type DiscordOptions = typeof DiscordOptionsObj;
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void) => {
registerRefreshOnce(refreshMenu); registerRefreshOnce(refreshMenu);
return [ return [
@ -23,7 +29,7 @@ module.exports = (win, options, refreshMenu) => {
label: 'Auto reconnect', label: 'Auto reconnect',
type: 'checkbox', type: 'checkbox',
checked: options.autoReconnect, checked: options.autoReconnect,
click(item) { click(item: Electron.MenuItem) {
options.autoReconnect = item.checked; options.autoReconnect = item.checked;
setMenuOptions('discord', options); setMenuOptions('discord', options);
}, },
@ -36,7 +42,7 @@ module.exports = (win, options, refreshMenu) => {
label: 'Clear activity after timeout', label: 'Clear activity after timeout',
type: 'checkbox', type: 'checkbox',
checked: options.activityTimoutEnabled, checked: options.activityTimoutEnabled,
click(item) { click(item: Electron.MenuItem) {
options.activityTimoutEnabled = item.checked; options.activityTimoutEnabled = item.checked;
setMenuOptions('discord', options); setMenuOptions('discord', options);
}, },
@ -45,7 +51,7 @@ module.exports = (win, options, refreshMenu) => {
label: 'Listen Along', label: 'Listen Along',
type: 'checkbox', type: 'checkbox',
checked: options.listenAlong, checked: options.listenAlong,
click(item) { click(item: Electron.MenuItem) {
options.listenAlong = item.checked; options.listenAlong = item.checked;
setMenuOptions('discord', options); setMenuOptions('discord', options);
}, },
@ -54,7 +60,7 @@ module.exports = (win, options, refreshMenu) => {
label: 'Hide duration left', label: 'Hide duration left',
type: 'checkbox', type: 'checkbox',
checked: options.hideDurationLeft, checked: options.hideDurationLeft,
click(item) { click(item: Electron.MenuItem) {
options.hideDurationLeft = item.checked; options.hideDurationLeft = item.checked;
setMenuOptions('discord', options); setMenuOptions('discord', options);
}, },
@ -66,11 +72,11 @@ module.exports = (win, options, refreshMenu) => {
]; ];
}; };
async function setInactivityTimeout(win, options) { async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
const output = await prompt({ const output = await prompt({
title: 'Set Inactivity Timeout', title: 'Set Inactivity Timeout',
label: 'Enter inactivity timeout in seconds:', label: 'Enter inactivity timeout in seconds:',
value: Math.round((options.activityTimoutTime ?? 0) / 1e3), value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)),
type: 'counter', type: 'counter',
counterOptions: { minimum: 0, multiFire: true }, counterOptions: { minimum: 0, multiFire: true },
width: 450, width: 450,
@ -78,7 +84,7 @@ async function setInactivityTimeout(win, options) {
}, win); }, win);
if (output) { if (output) {
options.activityTimoutTime = Math.round(output * 1e3); options.activityTimoutTime = Math.round(~~output * 1e3);
setMenuOptions('discord', options); setMenuOptions('discord', options);
} }
} }

View File

@ -1,4 +1,9 @@
module.exports = (options) => { import config from '../../config';
const SkipSilencesOptionsObj = config.get('plugins.skip-silences');
type SkipSilencesOptions = typeof SkipSilencesOptionsObj;
export default (options: SkipSilencesOptions) => {
let isSilent = false; let isSilent = false;
let hasAudioStarted = false; let hasAudioStarted = false;
@ -6,7 +11,7 @@ module.exports = (options) => {
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest) const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
const interval = 2; // Ms const interval = 2; // Ms
const history = 10; const history = 10;
const speakingHistory = Array.from({ length: history }).fill(0); const speakingHistory = Array.from({ length: history }).fill(0) as number[];
document.addEventListener( document.addEventListener(
'audioCanPlay', 'audioCanPlay',
@ -53,11 +58,13 @@ module.exports = (options) => {
if (history == 0 // Silent if (history == 0 // Silent
&& !( && !(
video.paused video && (
|| video.seeking video.paused
|| video.ended || video.seeking
|| video.muted || video.ended
|| video.volume === 0 || video.muted
|| video.volume === 0
)
) )
) { ) {
isSilent = true; isSilent = true;
@ -66,7 +73,7 @@ module.exports = (options) => {
} }
speakingHistory.shift(); speakingHistory.shift();
speakingHistory.push(0 + (currentVolume > threshold)); speakingHistory.push(Number(currentVolume > threshold));
looper(); looper();
}, interval); }, interval);
@ -79,17 +86,17 @@ module.exports = (options) => {
return; return;
} }
if (isSilent && !video.paused) { if (isSilent && video && !video.paused) {
video.currentTime += 0.2; // In s video.currentTime += 0.2; // In s
} }
}; };
video.addEventListener('play', () => { video?.addEventListener('play', () => {
hasAudioStarted = false; hasAudioStarted = false;
skipSilence(); skipSilence();
}); });
video.addEventListener('seeked', () => { video?.addEventListener('seeked', () => {
hasAudioStarted = false; hasAudioStarted = false;
skipSilence(); skipSilence();
}); });
@ -100,7 +107,7 @@ module.exports = (options) => {
); );
}; };
function getMaxVolume(analyser, fftBins) { function getMaxVolume(analyser: AnalyserNode, fftBins: Float32Array) {
let maxVolume = Number.NEGATIVE_INFINITY; let maxVolume = Number.NEGATIVE_INFINITY;
analyser.getFloatFrequencyData(fftBins); analyser.getFloatFrequencyData(fftBins);

View File

@ -1,12 +1,19 @@
const path = require('node:path'); import path from 'node:path';
const getSongControls = require('../../providers/song-controls'); import { BrowserWindow, nativeImage } from 'electron';
const registerCallback = require('../../providers/song-info');
let controls; import getSongControls from '../../providers/song-controls';
let currentSongInfo; import registerCallback, { SongInfo } from '../../providers/song-info';
module.exports = (win) => {
let controls: {
playPause: () => void;
next: () => void;
previous: () => void;
};
let currentSongInfo: SongInfo;
export default (win: BrowserWindow) => {
const { playPause, next, previous } = getSongControls(win); const { playPause, next, previous } = getSongControls(win);
controls = { playPause, next, previous }; controls = { playPause, next, previous };
@ -23,7 +30,7 @@ module.exports = (win) => {
}); });
}; };
function setThumbar(win, songInfo) { function setThumbar(win: BrowserWindow, songInfo: SongInfo) {
// Wait for song to start before setting thumbar // Wait for song to start before setting thumbar
if (!songInfo?.title) { if (!songInfo?.title) {
return; return;
@ -33,28 +40,28 @@ function setThumbar(win, songInfo) {
win.setThumbarButtons([ win.setThumbarButtons([
{ {
tooltip: 'Previous', tooltip: 'Previous',
icon: get('previous'), icon: nativeImage.createFromPath(get('previous')),
click() { click() {
controls.previous(win.webContents); controls.previous();
}, },
}, { }, {
tooltip: 'Play/Pause', tooltip: 'Play/Pause',
// Update icon based on play state // Update icon based on play state
icon: songInfo.isPaused ? get('play') : get('pause'), icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
click() { click() {
controls.playPause(win.webContents); controls.playPause();
}, },
}, { }, {
tooltip: 'Next', tooltip: 'Next',
icon: get('next'), icon: nativeImage.createFromPath(get('next')),
click() { click() {
controls.next(win.webContents); controls.next();
}, },
}, },
]); ]);
} }
// Util // Util
function get(kind) { function get(kind: string) {
return path.join(__dirname, '../../assets/media-icons-black', `${kind}.png`); return path.join(__dirname, '../../assets/media-icons-black', `${kind}.png`);
} }

View File

@ -1,4 +1,7 @@
const { TouchBar } = require('electron'); import { TouchBar, NativeImage, BrowserWindow } from 'electron';
import registerCallback from '../../providers/song-info';
import getSongControls from '../../providers/song-controls';
const { const {
TouchBarButton, TouchBarButton,
@ -8,21 +11,20 @@ const {
TouchBarScrubber, TouchBarScrubber,
} = TouchBar; } = TouchBar;
const registerCallback = require('../../providers/song-info');
const getSongControls = require('../../providers/song-controls');
// Songtitle label // Songtitle label
const songTitle = new TouchBarLabel({ const songTitle = new TouchBarLabel({
label: '', label: '',
}); });
// This will store the song controls once available // This will store the song controls once available
let controls = []; let controls: (() => void)[] = [];
// This will store the song image once available // This will store the song image once available
const songImage = {}; const songImage: {
icon?: NativeImage;
} = {};
// Pause/play button // Pause/play button
const pausePlayButton = new TouchBarButton(); const pausePlayButton = new TouchBarButton({});
// The song control buttons (control functions are in the same order) // The song control buttons (control functions are in the same order)
const buttons = new TouchBarSegmentedControl({ const buttons = new TouchBarSegmentedControl({
@ -59,7 +61,7 @@ const touchBar = new TouchBar({
], ],
}); });
module.exports = (win) => { module.exports = (win: BrowserWindow) => {
const { playPause, next, previous, dislike, like } = getSongControls(win); const { playPause, next, previous, dislike, like } = getSongControls(win);
// If the page is ready, register the callback // If the page is ready, register the callback
@ -79,7 +81,7 @@ module.exports = (win) => {
// Get image source // Get image source
songImage.icon = songInfo.image songImage.icon = songInfo.image
? songInfo.image.resize({ height: 23 }) ? songInfo.image.resize({ height: 23 })
: null; : undefined;
win.setTouchBar(touchBar); win.setTouchBar(touchBar);
}); });

View File

@ -1,13 +1,26 @@
const { ipcMain, net } = require('electron'); import { ipcMain, net, BrowserWindow } from 'electron';
const registerCallback = require('../../providers/song-info'); import registerCallback from '../../providers/song-info';
const secToMilisec = (t) => Math.round(Number(t) * 1e3); const secToMilisec = (t: number) => Math.round(Number(t) * 1e3);
const data = {
interface Data {
album: string | null | undefined;
album_url: string;
artists: string[];
cover: string;
cover_url: string;
duration: number;
progress: number;
status: string;
title: string;
}
const data: Data = {
cover: '', cover: '',
cover_url: '', cover_url: '',
title: '', title: '',
artists: [], artists: [] as string[],
status: '', status: '',
progress: 0, progress: 0,
duration: 0, duration: 0,
@ -15,7 +28,7 @@ const data = {
album: undefined, album: undefined,
}; };
const post = async (data) => { const post = (data: Data) => {
const port = 1608; const port = 1608;
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -28,13 +41,12 @@ const post = async (data) => {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ data }), body: JSON.stringify({ data }),
}).catch((error) => console.log(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`)); }).catch((error: { code: number, errno: number }) => console.log(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`));
}; };
/** @param {Electron.BrowserWindow} win */ module.exports = (win: BrowserWindow) => {
module.exports = async (win) => {
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', async (_, t) => { ipcMain.on('timeChanged', (_, t: number) => {
if (!data.title) { if (!data.title) {
return; return;
} }
@ -50,9 +62,9 @@ module.exports = async (win) => {
data.duration = secToMilisec(songInfo.songDuration); data.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds); data.progress = secToMilisec(songInfo.elapsedSeconds);
data.cover = songInfo.imageSrc; data.cover = songInfo.imageSrc ?? '';
data.cover_url = songInfo.imageSrc; data.cover_url = songInfo.imageSrc ?? '';
data.album_url = songInfo.imageSrc; data.album_url = songInfo.imageSrc ?? '';
data.title = songInfo.title; data.title = songInfo.title;
data.artists = [songInfo.artist]; data.artists = [songInfo.artist];
data.status = songInfo.isPaused ? 'stopped' : 'playing'; data.status = songInfo.isPaused ? 'stopped' : 'playing';

View File

@ -7,11 +7,12 @@ import { ValueOf } from '../utils/type-utils';
// Creates a DOM element from an HTML string // Creates a DOM element from an HTML string
export const ElementFromHtml = (html: string) => { export const ElementFromHtml = (html: string): HTMLElement => {
const template = document.createElement('template'); const template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html; template.innerHTML = html;
return template.content.firstChild;
return template.content.firstElementChild as HTMLElement;
}; };
// Creates a DOM element from a HTML file // Creates a DOM element from a HTML file

View File

@ -88,7 +88,7 @@ const handleData = async (responseText: string, win: Electron.BrowserWindow) =>
}; };
// This variable will be filled with the callbacks once they register // This variable will be filled with the callbacks once they register
type SongInfoCallback = (songInfo: SongInfo, event: string) => void; export type SongInfoCallback = (songInfo: SongInfo, event?: string) => void;
const callbacks: SongInfoCallback[] = []; const callbacks: SongInfoCallback[] = [];
// This function will allow plugins to register callback that will be triggered when data changes // This function will allow plugins to register callback that will be triggered when data changes

19
reset.d.ts vendored
View File

@ -2,8 +2,14 @@ import '@total-typescript/ts-reset';
import { YoutubePlayer } from './types/youtube-player'; import { YoutubePlayer } from './types/youtube-player';
declare global { declare global {
interface Compressor {
audioSource: MediaElementAudioSourceNode;
audioContext: AudioContext;
}
interface DocumentEventMap { interface DocumentEventMap {
'apiLoaded': CustomEvent<YoutubePlayer>; 'apiLoaded': CustomEvent<YoutubePlayer>;
'audioCanPlay': CustomEvent<Compressor>;
} }
interface Window { interface Window {
@ -11,5 +17,18 @@ declare global {
* YouTube Music internal variable (Last interaction time) * YouTube Music internal variable (Last interaction time)
*/ */
_lact: number; _lact: number;
navigation: Navigation;
}
}
// import { Howl as _Howl } from 'howler';
declare module 'howler' {
interface Howl {
_sounds: {
_paused: boolean;
_ended: boolean;
_id: string;
_node: HTMLMediaElement;
}[];
} }
} }

View File

@ -1,6 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"lib": ["dom"],
"module": "CommonJS", "module": "CommonJS",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,

View File

@ -140,9 +140,9 @@ export interface YoutubePlayer {
getPlaylistId: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getPlaylistId: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
loadModule: <Parameters extends unknown[], Return>(...params: Parameters) => Return; loadModule: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
unloadModule: <Parameters extends unknown[], Return>(...params: Parameters) => Return; unloadModule: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setOption: <Parameters extends unknown[], Return>(...params: Parameters) => Return; setOption: <T>(optionName: string, key: string, value: T) => void;
getOption: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getOption: <T>(optionName: string, key: string) => T | null | undefined;
getOptions: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getOptions: () => string[];
mute: <Parameters extends unknown[], Return>(...params: Parameters) => Return; mute: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
unMute: <Parameters extends unknown[], Return>(...params: Parameters) => Return; unMute: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isMuted: <Parameters extends unknown[], Return>(...params: Parameters) => Return; isMuted: <Parameters extends unknown[], Return>(...params: Parameters) => Return;