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': {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
disabledCaptions: false,
},
'skip-silences': {
onlySkipBeginning: false,

View File

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

View File

@ -24,8 +24,8 @@ declare module 'custom-electron-prompt' {
cancel?: string;
};
alwaysOnTop?: boolean;
value?: string;
type?: 'input' | 'select' | 'counter';
value?: unknown;
type?: 'input' | 'select' | 'counter' | 'multiInput';
selectOptions?: Record<string, string>;
keybindOptions?: PromptKeybindOptions[];
counterOptions?: PromptCounterOptions;
@ -37,7 +37,13 @@ declare module 'custom-electron-prompt' {
frame?: boolean;
customScript?: string;
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>;

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": {
"@playwright/test": "1.37.1",
"@total-typescript/ts-reset": "0.5.1",
"@types/howler": "^2.2.8",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0",
@ -1200,6 +1201,12 @@
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.12.tgz",
"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": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",

View File

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

View File

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

View File

@ -1,15 +1,13 @@
import config from './config';
export default async () => {
const blockerConfig = await config.get('blocker');
import config, { blockers } from './config';
export default () => {
return [
{
label: 'Blocker',
submenu: Object.values(config.blockers).map((blocker) => ({
submenu: Object.values(blockers).map((blocker: string) => ({
label: blocker,
type: 'radio',
checked: (blockerConfig || config.blockers.WithBlocklists) === blocker,
checked: (config.get('blocker') || blockers.WithBlocklists) === blocker,
click() {
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
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) {
return document.querySelector(selector);
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;
}
let config: ConfigType<'captions-selector'>;
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
const captionsSettingsButton = ElementFromFile(
templatePath(__dirname, 'captions-settings-template.html'),
);
module.exports = async () => {
export default async () => {
// RENDERER
config = await configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
@ -23,12 +40,12 @@ module.exports = async () => {
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
};
function setup(api) {
function setup(api: YoutubePlayer) {
$('.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) {
setTimeout(() => api.unloadModule('captions'), 100);
captionsSettingsButton.style.display = 'none';
@ -37,8 +54,8 @@ function setup(api) {
api.loadModule('captions');
setTimeout(async () => {
captionTrackList = api.getOption('captions', 'tracklist');
setTimeout(() => {
captionTrackList = api.getOption('captions', 'tracklist') ?? [];
if (config.autoload && config.lastCaptionsCode) {
api.setOption('captions', 'track', {
@ -54,9 +71,9 @@ function setup(api) {
captionsSettingsButton.addEventListener('click', async () => {
if (captionTrackList?.length) {
const currentCaptionTrack = api.getOption('captions', 'track');
const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
let currentIndex = currentCaptionTrack
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode))
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!)
: null;
const captionLabels = [
@ -64,7 +81,7 @@ function setup(api) {
'None',
];
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex);
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
if (currentIndex === null) {
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',
type: 'checkbox',

View File

@ -1,10 +1,10 @@
module.exports = () => {
export default () => {
const compactSidebar = document.querySelector('#mini-guide');
const isCompactSidebarDisabled
= compactSidebar === null
|| window.getComputedStyle(compactSidebar).display === 'none';
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';
// 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?
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
// 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
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
*
@ -38,13 +81,8 @@ class VolumeFader {
* @param options {Object} - an object with optional settings
* @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?
if (media instanceof HTMLMediaElement) {
// Save reference to media element
@ -70,8 +108,8 @@ class VolumeFader {
if (options.fadeScaling === 'linear') {
// Pass levels unchanged
this.scale = {
internalToVolume: (level) => level,
volumeToInternal: (level) => level,
internalToVolume: (level: number) => level,
volumeToInternal: (level: number) => level,
};
// Log setting
@ -79,7 +117,7 @@ class VolumeFader {
}
// No linear, but logarithmic fading…
else {
let dynamicRange;
let dynamicRange: number;
// Default dynamic range?
if (
@ -91,7 +129,8 @@ class VolumeFader {
}
// Custom dynamic range?
else if (
!Number.isNaN(options.fadeScaling)
typeof options.fadeScaling === 'number'
&& !Number.isNaN(options.fadeScaling)
&& options.fadeScaling > 0
) {
// Turn amplitude dB into a multiple of 10 power dB
@ -107,9 +146,9 @@ class VolumeFader {
// Use exponential/logarithmic scaler for expansion/compression
this.scale = {
internalToVolume: (level) =>
internalToVolume: (level: number) =>
this.exponentialScaler(level, dynamicRange),
volumeToInternal: (level) =>
volumeToInternal: (level: number) =>
this.logarithmicScaler(level, dynamicRange),
};
@ -193,7 +232,7 @@ class VolumeFader {
* @throws {TypeError} if fadeDuration is not a number greater than zero
* @return {Object} VolumeFader instance for chaining
*/
setFadeDuration(fadeDuration) {
setFadeDuration(fadeDuration: number) {
// If duration is a valid number > 0…
if (!Number.isNaN(fadeDuration) && fadeDuration > 0) {
// Set fade duration
@ -219,7 +258,7 @@ class VolumeFader {
* @throws {TypeError} if targetVolume is not in the range 01
* @return {Object} VolumeFader instance for chaining
*/
fadeTo(targetVolume, callback) {
fadeTo(targetVolume: number, callback?: () => void) {
// Validate volume and throw if invalid
validateVolumeLevel(targetVolume);
@ -250,11 +289,11 @@ class VolumeFader {
}
// Convenience shorthand methods for common fades
fadeIn(callback) {
fadeIn(callback: () => void) {
this.fadeTo(1, callback);
}
fadeOut(callback) {
fadeOut(callback: () => void) {
this.fadeTo(0, callback);
}
@ -313,7 +352,7 @@ class VolumeFader {
* @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0)
* @return {Number} - expanded level (float, 01)
*/
exponentialScaler(input, dynamicRange) {
exponentialScaler(input: number, dynamicRange: number) {
// Special case: make zero (or any falsy input) return zero
if (input === 0) {
// 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)
* @return {Number} - compressed level (float, 01)
*/
logarithmicScaler(input, dynamicRange) {
logarithmicScaler(input: number, dynamicRange: number) {
// Special case: make zero (or any falsy input) return zero
if (input === 0) {
// Logarithm of zero would be -∞, which would map to zero anyway
@ -351,6 +390,6 @@ class VolumeFader {
}
}
module.exports = {
export default {
VolumeFader
};

View File

@ -1,38 +1,39 @@
const { ipcRenderer } = require('electron');
const { Howl } = require('howler');
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { ipcRenderer } from 'electron';
import { Howl } from 'howler';
// 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 waitForTransition;
let waitForTransition: Promise<unknown>;
/**
* @type {PluginConfig}
*/
const configProvider = require('./config');
const defaultConfig = defaultConfigs.plugins.crossfade;
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) => {
return await ipcRenderer.invoke('audio-url', videoID);
};
const getVideoIDFromURL = (url) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
const watchVideoIDChanges = (cb) => {
const watchVideoIDChanges = (cb: (id: string) => void) => {
window.navigation.addEventListener('navigate', (event) => {
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 (
nextVideoID
@ -51,7 +52,7 @@ const watchVideoIDChanges = (cb) => {
});
};
const createAudioForCrossfade = async (url) => {
const createAudioForCrossfade = (url: string) => {
if (transitionAudio) {
transitionAudio.unload();
}
@ -61,19 +62,19 @@ const createAudioForCrossfade = async (url) => {
html5: true,
volume: 0,
});
await syncVideoWithTransitionAudio();
syncVideoWithTransitionAudio();
};
const syncVideoWithTransitionAudio = async () => {
const video = document.querySelector('video');
const syncVideoWithTransitionAudio = () => {
const video = document.querySelector('video')!;
const videoFader = new VolumeFader(video, {
fadeScaling: configGetNumber('fadeScaling'),
fadeDuration: configGetNumber('fadeInDuration'),
});
await transitionAudio.play();
await transitionAudio.seek(video.currentTime);
transitionAudio.play();
transitionAudio.seek(video.currentTime);
video.addEventListener('seeking', () => {
transitionAudio.seek(video.currentTime);
@ -83,9 +84,9 @@ const syncVideoWithTransitionAudio = async () => {
transitionAudio.pause();
});
video.addEventListener('play', async () => {
await transitionAudio.play();
await transitionAudio.seek(video.currentTime);
video.addEventListener('play', () => {
transitionAudio.play();
transitionAudio.seek(video.currentTime);
// Fade in
const videoVolume = video.volume;
@ -102,7 +103,7 @@ const syncVideoWithTransitionAudio = async () => {
video.removeEventListener('timeupdate', transitionBeforeEnd);
// 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()) {
cb();
return;
}
let resolveTransition;
waitForTransition = new Promise((resolve) => {
let resolveTransition: () => void;
waitForTransition = new Promise<void>((resolve) => {
resolveTransition = resolve;
});
const video = document.querySelector('video');
const video = document.querySelector('video')!;
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume,
@ -148,7 +149,7 @@ const crossfade = async (cb) => {
});
};
module.exports = async () => {
export default async () => {
config = await configProvider.getAll();
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');
const defaultOptions = require('../../config/defaults').plugins.crossfade;
import config from './config';
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',
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(
{
title: 'Crossfade Options',
@ -29,8 +34,8 @@ async function promptCrossfadeValues(win, options) {
inputAttrs: {
type: 'number',
required: true,
min: 0,
step: 100,
min: '0',
step: '100',
},
},
{
@ -39,8 +44,8 @@ async function promptCrossfadeValues(win, options) {
inputAttrs: {
type: 'number',
required: true,
min: 0,
step: 100,
min: '0',
step: '100',
},
},
{
@ -50,7 +55,7 @@ async function promptCrossfadeValues(win, options) {
inputAttrs: {
type: 'number',
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';
const { dialog, app } = require('electron');
const Discord = require('@xhayper/discord-rpc');
const { dev } = require('electron-is');
import { app, dialog } from 'electron';
import Discord from '@xhayper/discord-rpc';
import { dev } from '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
const clientId = '1043858434585526382';
/**
* @typedef {Object} Info
* @property {import('@xhayper/discord-rpc').Client} rpc
* @property {boolean} ready
* @property {boolean} autoReconnect
* @property {import('../../providers/song-info').SongInfo} lastSongInfo
*/
/**
* @type {Info}
*/
const info = {
export interface Info {
rpc: import('@xhayper/discord-rpc').Client;
ready: boolean;
autoReconnect: boolean;
lastSongInfo?: import('../../providers/song-info').SongInfo;
}
const info: Info = {
rpc: new Discord.Client({
clientId,
}),
ready: false,
autoReconnect: true,
lastSongInfo: null,
lastSongInfo: undefined,
};
/**
* @type {(() => void)[]}
*/
const refreshCallbacks = [];
const refreshCallbacks: (() => void)[] = [];
const resetInfo = () => {
info.ready = false;
@ -85,8 +84,8 @@ const connectRecursive = () => {
connectTimeout().catch(connectRecursive);
};
let window;
const connect = (showError = false) => {
let window: Electron.BrowserWindow;
export const connect = (showError = false) => {
if (info.rpc.isConnected) {
if (dev()) {
console.log('Attempted to connect with active connection');
@ -98,7 +97,7 @@ const connect = (showError = false) => {
info.ready = false;
// Startup the rpc client
info.rpc.login({ clientId }).catch((error) => {
info.rpc.login().catch((error: Error) => {
resetInfo();
if (dev()) {
console.error(error);
@ -116,14 +115,17 @@ const connect = (showError = false) => {
});
};
let clearActivity;
/**
* @type {import('../../providers/song-info').songInfoCallback}
*/
let updateActivity;
let clearActivity: NodeJS.Timeout | undefined;
let updateActivity: import('../../providers/song-info').SongInfoCallback;
module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTime, listenAlong, hideDurationLeft }) => {
info.autoReconnect = autoReconnect;
const DiscordOptionsObj = pluginConfig.get('plugins.discord');
type DiscordOptions = typeof DiscordOptionsObj;
export default (
win: Electron.BrowserWindow,
options: DiscordOptions,
) => {
info.autoReconnect = options.autoReconnect;
window = win;
// We get multiple events
@ -146,7 +148,7 @@ module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTim
}
// 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);
return;
}
@ -154,12 +156,12 @@ module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTim
// 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 activityInfo = {
const activityInfo: SetActivity = {
details: songInfo.title,
state: songInfo.artist,
largeImageKey: songInfo.imageSrc,
largeImageText: songInfo.album,
buttons: listenAlong ? [
largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album ?? '',
buttons: options.listenAlong ? [
{ label: 'Listen Along', url: songInfo.url },
] : undefined,
};
@ -169,10 +171,10 @@ module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTim
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
// Set start the timer so the activity gets cleared after a while if enabled
if (activityTimoutEnabled) {
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), activityTimoutTime ?? 10_000);
if (options.activityTimoutEnabled) {
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
const songStartTime = Date.now() - (songInfo.elapsedSeconds * 1000);
activityInfo.startTimestamp = songStartTime;
@ -188,10 +190,10 @@ module.exports = (win, { autoReconnect, activityTimoutEnabled, activityTimoutTim
registerCallback(updateActivity);
connect();
});
app.on('window-all-closed', module.exports.clear);
app.on('window-all-closed', clear);
};
module.exports.clear = () => {
export const clear = () => {
if (info.rpc) {
info.rpc.user?.clearActivity();
}
@ -199,6 +201,5 @@ module.exports.clear = () => {
clearTimeout(clearActivity);
};
module.exports.connect = connect;
module.exports.registerRefresh = (cb) => refreshCallbacks.push(cb);
module.exports.isConnected = () => info.rpc !== null;
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
export const 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');
const promptOptions = require('../../providers/prompt-options');
const { singleton } = require('../../providers/decorators');
import { clear, connect, isConnected, registerRefresh } from './back';
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);
});
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);
return [
@ -23,7 +29,7 @@ module.exports = (win, options, refreshMenu) => {
label: 'Auto reconnect',
type: 'checkbox',
checked: options.autoReconnect,
click(item) {
click(item: Electron.MenuItem) {
options.autoReconnect = item.checked;
setMenuOptions('discord', options);
},
@ -36,7 +42,7 @@ module.exports = (win, options, refreshMenu) => {
label: 'Clear activity after timeout',
type: 'checkbox',
checked: options.activityTimoutEnabled,
click(item) {
click(item: Electron.MenuItem) {
options.activityTimoutEnabled = item.checked;
setMenuOptions('discord', options);
},
@ -45,7 +51,7 @@ module.exports = (win, options, refreshMenu) => {
label: 'Listen Along',
type: 'checkbox',
checked: options.listenAlong,
click(item) {
click(item: Electron.MenuItem) {
options.listenAlong = item.checked;
setMenuOptions('discord', options);
},
@ -54,7 +60,7 @@ module.exports = (win, options, refreshMenu) => {
label: 'Hide duration left',
type: 'checkbox',
checked: options.hideDurationLeft,
click(item) {
click(item: Electron.MenuItem) {
options.hideDurationLeft = item.checked;
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({
title: 'Set Inactivity Timeout',
label: 'Enter inactivity timeout in seconds:',
value: Math.round((options.activityTimoutTime ?? 0) / 1e3),
value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)),
type: 'counter',
counterOptions: { minimum: 0, multiFire: true },
width: 450,
@ -78,7 +84,7 @@ async function setInactivityTimeout(win, options) {
}, win);
if (output) {
options.activityTimoutTime = Math.round(output * 1e3);
options.activityTimoutTime = Math.round(~~output * 1e3);
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 hasAudioStarted = false;
@ -6,7 +11,7 @@ module.exports = (options) => {
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
const interval = 2; // Ms
const history = 10;
const speakingHistory = Array.from({ length: history }).fill(0);
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
document.addEventListener(
'audioCanPlay',
@ -53,11 +58,13 @@ module.exports = (options) => {
if (history == 0 // Silent
&& !(
video.paused
|| video.seeking
|| video.ended
|| video.muted
|| video.volume === 0
video && (
video.paused
|| video.seeking
|| video.ended
|| video.muted
|| video.volume === 0
)
)
) {
isSilent = true;
@ -66,7 +73,7 @@ module.exports = (options) => {
}
speakingHistory.shift();
speakingHistory.push(0 + (currentVolume > threshold));
speakingHistory.push(Number(currentVolume > threshold));
looper();
}, interval);
@ -79,17 +86,17 @@ module.exports = (options) => {
return;
}
if (isSilent && !video.paused) {
if (isSilent && video && !video.paused) {
video.currentTime += 0.2; // In s
}
};
video.addEventListener('play', () => {
video?.addEventListener('play', () => {
hasAudioStarted = false;
skipSilence();
});
video.addEventListener('seeked', () => {
video?.addEventListener('seeked', () => {
hasAudioStarted = false;
skipSilence();
});
@ -100,7 +107,7 @@ module.exports = (options) => {
);
};
function getMaxVolume(analyser, fftBins) {
function getMaxVolume(analyser: AnalyserNode, fftBins: Float32Array) {
let maxVolume = Number.NEGATIVE_INFINITY;
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');
const registerCallback = require('../../providers/song-info');
import { BrowserWindow, nativeImage } from 'electron';
let controls;
let currentSongInfo;
import getSongControls from '../../providers/song-controls';
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);
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
if (!songInfo?.title) {
return;
@ -33,28 +40,28 @@ function setThumbar(win, songInfo) {
win.setThumbarButtons([
{
tooltip: 'Previous',
icon: get('previous'),
icon: nativeImage.createFromPath(get('previous')),
click() {
controls.previous(win.webContents);
controls.previous();
},
}, {
tooltip: 'Play/Pause',
// Update icon based on play state
icon: songInfo.isPaused ? get('play') : get('pause'),
icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
click() {
controls.playPause(win.webContents);
controls.playPause();
},
}, {
tooltip: 'Next',
icon: get('next'),
icon: nativeImage.createFromPath(get('next')),
click() {
controls.next(win.webContents);
controls.next();
},
},
]);
}
// Util
function get(kind) {
function get(kind: string) {
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 {
TouchBarButton,
@ -8,21 +11,20 @@ const {
TouchBarScrubber,
} = TouchBar;
const registerCallback = require('../../providers/song-info');
const getSongControls = require('../../providers/song-controls');
// Songtitle label
const songTitle = new TouchBarLabel({
label: '',
});
// This will store the song controls once available
let controls = [];
let controls: (() => void)[] = [];
// This will store the song image once available
const songImage = {};
const songImage: {
icon?: NativeImage;
} = {};
// Pause/play button
const pausePlayButton = new TouchBarButton();
const pausePlayButton = new TouchBarButton({});
// The song control buttons (control functions are in the same order)
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);
// If the page is ready, register the callback
@ -79,7 +81,7 @@ module.exports = (win) => {
// Get image source
songImage.icon = songInfo.image
? songInfo.image.resize({ height: 23 })
: null;
: undefined;
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 data = {
const secToMilisec = (t: number) => Math.round(Number(t) * 1e3);
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_url: '',
title: '',
artists: [],
artists: [] as string[],
status: '',
progress: 0,
duration: 0,
@ -15,7 +28,7 @@ const data = {
album: undefined,
};
const post = async (data) => {
const post = (data: Data) => {
const port = 1608;
const headers = {
'Content-Type': 'application/json',
@ -28,13 +41,12 @@ const post = async (data) => {
method: 'POST',
headers,
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 = async (win) => {
module.exports = (win: BrowserWindow) => {
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', async (_, t) => {
ipcMain.on('timeChanged', (_, t: number) => {
if (!data.title) {
return;
}
@ -50,9 +62,9 @@ module.exports = async (win) => {
data.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds);
data.cover = songInfo.imageSrc;
data.cover_url = songInfo.imageSrc;
data.album_url = songInfo.imageSrc;
data.cover = songInfo.imageSrc ?? '';
data.cover_url = songInfo.imageSrc ?? '';
data.album_url = songInfo.imageSrc ?? '';
data.title = songInfo.title;
data.artists = [songInfo.artist];
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
export const ElementFromHtml = (html: string) => {
export const ElementFromHtml = (html: string): HTMLElement => {
const template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
return template.content.firstElementChild as HTMLElement;
};
// 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
type SongInfoCallback = (songInfo: SongInfo, event: string) => void;
export type SongInfoCallback = (songInfo: SongInfo, event?: string) => void;
const callbacks: SongInfoCallback[] = [];
// 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';
declare global {
interface Compressor {
audioSource: MediaElementAudioSourceNode;
audioContext: AudioContext;
}
interface DocumentEventMap {
'apiLoaded': CustomEvent<YoutubePlayer>;
'audioCanPlay': CustomEvent<Compressor>;
}
interface Window {
@ -11,5 +17,18 @@ declare global {
* YouTube Music internal variable (Last interaction time)
*/
_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": {
"target": "ESNext",
"lib": ["dom"],
"module": "CommonJS",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,

View File

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