convert plugins

This commit is contained in:
JellyBrick
2023-11-27 18:41:50 +09:00
parent 4fad456619
commit 3ffbfbe0e3
70 changed files with 1617 additions and 1836 deletions

View File

@ -6,7 +6,7 @@ import defaultConfig from './defaults';
import store from './store'; import store from './store';
import plugins from './plugins'; import plugins from './plugins';
import { restart } from '../providers/app-controls'; import { restart } from '@/providers/app-controls';
const set = (key: string, value: unknown) => { const set = (key: string, value: unknown) => {
store.set(key, value); store.set(key, value);

View File

@ -5,7 +5,7 @@ import { allPlugins } from 'virtual:plugins';
import defaults from './defaults'; import defaults from './defaults';
import { DefaultPresetList, type Preset } from '../plugins/downloader/types'; import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
const setDefaultPluginOptions = ( const setDefaultPluginOptions = (
store: Conf<Record<string, unknown>>, store: Conf<Record<string, unknown>>,

View File

@ -10,7 +10,7 @@ import { startPlugin, stopPlugin } from '@/utils';
const unregisterStyleMap: Record<string, (() => void)[]> = {}; const unregisterStyleMap: Record<string, (() => void)[]> = {};
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {}; const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
const createContext = <Config extends PluginConfig>(id: string): RendererContext<Config> => ({ export const createContext = <Config extends PluginConfig>(id: string): RendererContext<Config> => ({
getConfig: () => window.mainConfig.plugins.getOptions(id), getConfig: () => window.mainConfig.plugins.getOptions(id),
setConfig: async (newConfig) => { setConfig: async (newConfig) => {
await window.ipcRenderer.invoke('set-config', id, newConfig); await window.ipcRenderer.invoke('set-config', id, newConfig);

View File

@ -9,15 +9,13 @@ import {
} from 'electron'; } from 'electron';
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import { allPlugins } from 'virtual:plugins';
import { restart } from './providers/app-controls'; import { restart } from './providers/app-controls';
import config from './config'; import config from './config';
import { startingPages } from './providers/extracted-data'; import { startingPages } from './providers/extracted-data';
import promptOptions from './providers/prompt-options'; import promptOptions from './providers/prompt-options';
/* eslint-disable import/order */
import { allPlugins } from 'virtual:plugins';
/* eslint-enable import/order */
import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu'; import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
export type MenuTemplate = Electron.MenuItemConstructorOptions[]; export type MenuTemplate = Electron.MenuItemConstructorOptions[];

View File

@ -1,9 +1,9 @@
import { blockers } from './types'; import { blockers } from './types';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from '@/plugins/adblocker/blocker'; import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from './blocker';
import injectCliqzPreload from '@/plugins/adblocker/injectors/inject-cliqz-preload'; import injectCliqzPreload from './injectors/inject-cliqz-preload';
import { inject, isInjected } from '@/plugins/adblocker/injectors/inject'; import { inject, isInjected } from './injectors/inject';
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';

View File

@ -8,7 +8,7 @@ import { Howl } from 'howler';
import promptOptions from '@/providers/prompt-options'; import promptOptions from '@/providers/prompt-options';
import { getNetFetchAsFetch } from '@/plugins/utils/main'; import { getNetFetchAsFetch } from '@/plugins/utils/main';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import { VolumeFader } from '@/plugins/crossfade/fader'; import { VolumeFader } from './fader';
import type { RendererContext } from '@/types/contexts'; import type { RendererContext } from '@/types/contexts';

View File

@ -1,6 +1,6 @@
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import { onLoad, onUnload } from '@/plugins/discord/main'; import { onLoad, onUnload } from './main';
import {onMenu} from "@/plugins/discord/menu"; import { onMenu } from './menu';
export type DiscordPluginConfig = { export type DiscordPluginConfig = {
enabled: boolean; enabled: boolean;

View File

@ -4,7 +4,7 @@ import { dev } from 'electron-is';
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser'; import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info'; import registerCallback, { type SongInfoCallback, type SongInfo } from '@/providers/song-info';
import type { DiscordPluginConfig } from './index'; import type { DiscordPluginConfig } from './index';

View File

@ -3,12 +3,11 @@ import prompt from 'custom-electron-prompt';
import { clear, connect, isConnected, registerRefresh } from './main'; import { clear, connect, isConnected, registerRefresh } from './main';
import { singleton } from '@/providers/decorators'; import { singleton } from '@/providers/decorators';
import promptOptions from '@/providers/prompt-options';
import { setMenuOptions } from '@/config/plugins'; import { setMenuOptions } from '@/config/plugins';
import { MenuContext } from '@/types/contexts'; import { MenuContext } from '@/types/contexts';
import { DiscordPluginConfig } from '@/plugins/discord/index'; import { DiscordPluginConfig } from '@/plugins/discord/index';
import promptOptions from '../../providers/prompt-options';
import type { MenuTemplate } from '@/menu'; import type { MenuTemplate } from '@/menu';
const registerRefreshOnce = singleton((refreshMenu: () => void) => { const registerRefreshOnce = singleton((refreshMenu: () => void) => {

View File

@ -3,8 +3,8 @@ import { DefaultPresetList, Preset } from './types';
import style from './style.css?inline'; import style from './style.css?inline';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import { onConfigChange, onMainLoad } from '@/plugins/downloader/main'; import { onConfigChange, onMainLoad } from './main';
import { onPlayerApiReady, onRendererLoad } from '@/plugins/downloader/renderer'; import { onPlayerApiReady, onRendererLoad } from './renderer';
export type DownloaderPluginConfig = { export type DownloaderPluginConfig = {
enabled: boolean; enabled: boolean;
@ -15,17 +15,19 @@ export type DownloaderPluginConfig = {
playlistMaxItems?: number; playlistMaxItems?: number;
} }
export const defaultConfig: DownloaderPluginConfig = {
enabled: false,
downloadFolder: undefined,
selectedPreset: 'mp3 (256kbps)', // Selected preset
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
skipExisting: false,
playlistMaxItems: undefined,
};
export default createPlugin({ export default createPlugin({
name: 'Downloader', name: 'Downloader',
restartNeeded: true, restartNeeded: true,
config: { config: defaultConfig,
enabled: false,
downloadFolder: undefined,
selectedPreset: 'mp3 (256kbps)', // Selected preset
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
skipExisting: false,
playlistMaxItems: undefined,
} as DownloaderPluginConfig,
stylesheets: [style], stylesheets: [style],
backend: { backend: {
start: onMainLoad, start: onMainLoad,

View File

@ -37,7 +37,7 @@ import { BackendContext } from '@/types/contexts';
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types'; import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
import { DownloaderPluginConfig } from '../index'; import { defaultConfig, type DownloaderPluginConfig } from '../index';
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils'; import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage'; import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
@ -90,7 +90,7 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
.join(';'); .join(';');
}; };
let config: DownloaderPluginConfig; let config: DownloaderPluginConfig = defaultConfig;
export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext<DownloaderPluginConfig>) => { export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext<DownloaderPluginConfig>) => {
win = _win; win = _win;

View File

@ -1,8 +1,8 @@
import titlebarStyle from './titlebar.css?inline'; import titlebarStyle from './titlebar.css?inline';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import { onMainLoad } from '@/plugins/in-app-menu/main'; import { onMainLoad } from './main';
import { onMenu } from '@/plugins/in-app-menu/menu'; import { onMenu } from './menu';
import { onPlayerApiReady, onRendererLoad } from '@/plugins/in-app-menu/renderer'; import { onPlayerApiReady, onRendererLoad } from './renderer';
export interface InAppMenuConfig { export interface InAppMenuConfig {
enabled: boolean; enabled: boolean;

View File

@ -1,4 +1,6 @@
import { createPluginBuilder } from '../utils/builder'; import { createPlugin } from '@/utils';
import registerCallback from '@/providers/song-info';
import { addScrobble, getAndSetSessionKey, setNowPlaying } from './main';
export interface LastFmPluginConfig { export interface LastFmPluginConfig {
enabled: boolean; enabled: boolean;
@ -30,7 +32,7 @@ export interface LastFmPluginConfig {
secret: string; secret: string;
} }
const builder = createPluginBuilder('last-fm', { export default createPlugin({
name: 'Last.fm', name: 'Last.fm',
restartNeeded: true, restartNeeded: true,
config: { config: {
@ -39,12 +41,34 @@ const builder = createPluginBuilder('last-fm', {
api_key: '04d76faaac8726e60988e14c105d421a', api_key: '04d76faaac8726e60988e14c105d421a',
secret: 'a5d2a36fdf64819290f6982481eaffa2', secret: 'a5d2a36fdf64819290f6982481eaffa2',
} as LastFmPluginConfig, } as LastFmPluginConfig,
}); async backend({ getConfig, setConfig }) {
let config = await getConfig();
// This will store the timeout that will trigger addScrobble
let scrobbleTimer: number | undefined;
export default builder; if (!config.api_root) {
config.enabled = true;
setConfig(config);
}
declare global { if (!config.session_key) {
interface PluginBuilderList { // Not authenticated
[builder.id]: typeof builder; config = await getAndSetSessionKey(config, setConfig);
}
registerCallback((songInfo) => {
// Set remove the old scrobble timer
clearTimeout(scrobbleTimer);
if (!songInfo.isPaused) {
setNowPlaying(songInfo, config, setConfig);
// Scrobble when the song is halfway through, or has passed the 4-minute mark
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
// Scrobble still needs to happen
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
}
}
});
} }
} });

View File

@ -2,10 +2,8 @@ import crypto from 'node:crypto';
import { net, shell } from 'electron'; import { net, shell } from 'electron';
import builder, { type LastFmPluginConfig } from './index'; import type { LastFmPluginConfig } from './index';
import type { SongInfo } from '@/providers/song-info';
import { setOptions } from '../../config/plugins';
import registerCallback, { type SongInfo } from '../../providers/song-info';
interface LastFmData { interface LastFmData {
method: string, method: string,
@ -53,7 +51,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
keys.sort(); keys.sort();
let sig = ''; let sig = '';
for (const key of keys) { for (const key of keys) {
if (String(key) === 'format') { if (key === 'format') {
continue; continue;
} }
@ -83,7 +81,9 @@ const authenticate = async (config: LastFmPluginConfig) => {
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`); await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
}; };
const getAndSetSessionKey = async (config: LastFmPluginConfig) => { type SetConfType = (conf: Partial<Omit<LastFmPluginConfig, 'enabled'>>) => (void | Promise<void>);
export const getAndSetSessionKey = async (config: LastFmPluginConfig, setConfig: SetConfType) => {
// Get and store the session key // Get and store the session key
const data = { const data = {
api_key: config.api_key, api_key: config.api_key,
@ -102,19 +102,19 @@ const getAndSetSessionKey = async (config: LastFmPluginConfig) => {
if (json.error) { if (json.error) {
config.token = await createToken(config); config.token = await createToken(config);
await authenticate(config); await authenticate(config);
setOptions('last-fm', config); setConfig(config);
} }
if (json.session) { if (json.session) {
config.session_key = json.session.key; config.session_key = json.session.key;
} }
setOptions('last-fm', config); setConfig(config);
return config; return config;
}; };
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData) => { const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData, setConfig: SetConfType) => {
// This sends a post request to the api, and adds the common data // This sends a post request to the api, and adds the common data
if (!config.session_key) { if (!config.session_key) {
await getAndSetSessionKey(config); await getAndSetSessionKey(config, setConfig);
} }
const postData: LastFmSongData = { const postData: LastFmSongData = {
@ -143,58 +143,24 @@ const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig,
config.session_key = undefined; config.session_key = undefined;
config.token = await createToken(config); config.token = await createToken(config);
await authenticate(config); await authenticate(config);
setOptions('last-fm', config); setConfig(config);
} }
}); });
}; };
const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig) => { export const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => {
// This adds one scrobbled song to last.fm // This adds one scrobbled song to last.fm
const data = { const data = {
method: 'track.scrobble', method: 'track.scrobble',
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000), timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
}; };
postSongDataToAPI(songInfo, config, data); postSongDataToAPI(songInfo, config, data, setConfig);
}; };
const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig) => { export const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => {
// This sets the now playing status in last.fm // This sets the now playing status in last.fm
const data = { const data = {
method: 'track.updateNowPlaying', method: 'track.updateNowPlaying',
}; };
postSongDataToAPI(songInfo, config, data); postSongDataToAPI(songInfo, config, data, setConfig);
}; };
// This will store the timeout that will trigger addScrobble
let scrobbleTimer: NodeJS.Timeout | undefined;
export default builder.createMain(({ getConfig, send }) => ({
async onLoad(_win) {
let config = await getConfig();
if (!config.api_root) {
config.enabled = true;
setOptions('last-fm', config);
}
if (!config.session_key) {
// Not authenticated
config = await getAndSetSessionKey(config);
}
registerCallback((songInfo) => {
// Set remove the old scrobble timer
clearTimeout(scrobbleTimer);
if (!songInfo.isPaused) {
setNowPlaying(songInfo, config);
// Scrobble when the song is halfway through, or has passed the 4-minute mark
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
// Scrobble still needs to happen
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
}
}
});
}
}));

View File

@ -1,17 +1,88 @@
import { createPluginBuilder } from '../utils/builder'; import { net } from 'electron';
const builder = createPluginBuilder('lumiastream', { import { createPlugin } from '@/utils';
import registerCallback from '@/providers/song-info';
type LumiaData = {
origin: string;
eventType: string;
url?: string;
videoId?: string;
playlistId?: string;
cover?: string|null;
cover_url?: string|null;
title?: string;
artists?: string[];
status?: string;
progress?: number;
duration?: number;
album_url?: string|null;
album?: string|null;
views?: number;
isPaused?: boolean;
}
export default createPlugin({
name: 'Lumia Stream [beta]', name: 'Lumia Stream [beta]',
restartNeeded: true, restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },
}); backend() {
const secToMilisec = (t?: number) => t ? Math.round(Number(t) * 1e3) : undefined;
const previousStatePaused = null;
export default builder; const data: LumiaData = {
origin: 'youtubemusic',
eventType: 'switchSong',
};
declare global { const post = (data: LumiaData) => {
interface PluginBuilderList { const port = 39231;
[builder.id]: typeof builder; const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': '*',
} as const;
const url = `http://127.0.0.1:${port}/api/media`;
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
.catch((error: { code: number, errno: number }) => {
console.log(
`Error: '${
error.code || error.errno
}' - when trying to access lumiastream webserver at port ${port}`
);
});
};
registerCallback((songInfo) => {
if (!songInfo.title && !songInfo.artist) {
return;
}
if (previousStatePaused === null) {
data.eventType = 'switchSong';
} else if (previousStatePaused !== songInfo.isPaused) {
data.eventType = 'playPause';
}
data.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds);
data.url = songInfo.url;
data.videoId = songInfo.videoId;
data.playlistId = songInfo.playlistId;
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';
data.isPaused = songInfo.isPaused;
data.album = songInfo.album;
data.views = songInfo.views;
post(data);
});
} }
} });

View File

@ -1,88 +0,0 @@
import { net } from 'electron';
import builder from './index';
import registerCallback from '../../providers/song-info';
const secToMilisec = (t?: number) => t ? Math.round(Number(t) * 1e3) : undefined;
const previousStatePaused = null;
type LumiaData = {
origin: string;
eventType: string;
url?: string;
videoId?: string;
playlistId?: string;
cover?: string|null;
cover_url?: string|null;
title?: string;
artists?: string[];
status?: string;
progress?: number;
duration?: number;
album_url?: string|null;
album?: string|null;
views?: number;
isPaused?: boolean;
}
const data: LumiaData = {
origin: 'youtubemusic',
eventType: 'switchSong',
};
const post = (data: LumiaData) => {
const port = 39231;
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': '*',
} as const;
const url = `http://127.0.0.1:${port}/api/media`;
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
.catch((error: { code: number, errno: number }) => {
console.log(
`Error: '${
error.code || error.errno
}' - when trying to access lumiastream webserver at port ${port}`
);
});
};
export default builder.createMain(() => {
return {
onLoad() {
registerCallback((songInfo) => {
if (!songInfo.title && !songInfo.artist) {
return;
}
if (previousStatePaused === null) {
data.eventType = 'switchSong';
} else if (previousStatePaused !== songInfo.isPaused) {
data.eventType = 'playPause';
}
data.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds);
data.url = songInfo.url;
data.videoId = songInfo.videoId;
data.playlistId = songInfo.playlistId;
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';
data.isPaused = songInfo.isPaused;
data.album = songInfo.album;
data.views = songInfo.views;
post(data);
});
}
};
});

View File

@ -1,26 +1,41 @@
import style from './style.css?inline'; import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { createPluginBuilder } from '../utils/builder'; import { onConfigChange, onMainLoad } from './main';
import { onRendererLoad } from './renderer';
export type LyricsGeniusPluginConfig = { export type LyricsGeniusPluginConfig = {
enabled: boolean; enabled: boolean;
romanizedLyrics: boolean; romanizedLyrics: boolean;
} }
const builder = createPluginBuilder('lyrics-genius', { export default createPlugin({
name: 'Lyrics Genius', name: 'Lyrics Genius',
restartNeeded: true, restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
romanizedLyrics: false, romanizedLyrics: false,
} as LyricsGeniusPluginConfig, } as LyricsGeniusPluginConfig,
styles: [style], stylesheets: [style],
async menu({ getConfig, setConfig }) {
const config = await getConfig();
return [
{
label: 'Romanized Lyrics',
type: 'checkbox',
checked: config.romanizedLyrics,
click(item) {
setConfig({
romanizedLyrics: item.checked,
});
},
},
];
},
backend: {
start: onMainLoad,
onConfigChange,
},
renderer: onRendererLoad,
}); });
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -3,33 +3,31 @@ import is from 'electron-is';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import { GetGeniusLyric } from './types'; import { GetGeniusLyric } from './types';
import { cleanupName, type SongInfo } from '@/providers/song-info';
import builder from './index'; import type { LyricsGeniusPluginConfig } from './index';
import { cleanupName, type SongInfo } from '../../providers/song-info'; import type { BackendContext } from '@/types/contexts';
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u; const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false; let revRomanized = false;
export default builder.createMain(({ handle, getConfig }) =>{ export const onMainLoad = async ({ ipc, getConfig }: BackendContext<LyricsGeniusPluginConfig>) => {
return { const config = await getConfig();
async onLoad() {
const config = await getConfig();
if (config.romanizedLyrics) { if (config.romanizedLyrics) {
revRomanized = true; revRomanized = true;
} }
handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => { ipc.handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => {
const metadata = extractedSongInfo; const metadata = extractedSongInfo;
return await fetchFromGenius(metadata); return await fetchFromGenius(metadata);
}); });
}, };
onConfigChange(newConfig) {
revRomanized = newConfig.romanizedLyrics; export const onConfigChange = (newConfig: LyricsGeniusPluginConfig) => {
} revRomanized = newConfig.romanizedLyrics;
}; };
});
export const fetchFromGenius = async (metadata: SongInfo) => { export const fetchFromGenius = async (metadata: SongInfo) => {
const songTitle = `${cleanupName(metadata.title)}`; const songTitle = `${cleanupName(metadata.title)}`;

View File

@ -1,18 +0,0 @@
import builder from './index';
export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{
label: 'Romanized Lyrics',
type: 'checkbox',
checked: config.romanizedLyrics,
click(item) {
setConfig({
romanizedLyrics: item.checked,
});
},
},
];
});

View File

@ -1,11 +1,10 @@
import builder from './index'; import type { SongInfo } from '@/providers/song-info';
import type { RendererContext } from '@/types/contexts';
import type { LyricsGeniusPluginConfig } from '@/plugins/lyrics-genius/index';
import type { SongInfo } from '../../providers/song-info'; export const onRendererLoad = ({ ipc: { invoke, on } }: RendererContext<LyricsGeniusPluginConfig>) => {
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
export default builder.createRenderer(({ on, invoke }) => ({ lyricsContainer.innerHTML = `
onLoad() {
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
lyricsContainer.innerHTML = `
<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics"> <div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
${lyrics?.replaceAll(/\r\n|\r|\n/g, '<br/>') ?? 'Could not retrieve lyrics from genius'} ${lyrics?.replaceAll(/\r\n|\r|\n/g, '<br/>') ?? 'Could not retrieve lyrics from genius'}
</div> </div>
@ -13,97 +12,96 @@ export default builder.createRenderer(({ on, invoke }) => ({
</yt-formatted-string> </yt-formatted-string>
`; `;
if (lyrics) { if (lyrics) {
const footer = lyricsContainer.querySelector('.footer'); const footer = lyricsContainer.querySelector('.footer');
if (footer) { if (footer) {
footer.textContent = 'Source: Genius'; footer.textContent = 'Source: Genius';
}
} }
}; }
};
let unregister: (() => void) | null = null; let unregister: (() => void) | null = null;
on('update-song-info', (extractedSongInfo: SongInfo) => { on('update-song-info', (extractedSongInfo: SongInfo) => {
unregister?.(); unregister?.();
setTimeout(async () => { setTimeout(async () => {
const tabList = document.querySelectorAll<HTMLElement>('tp-yt-paper-tab'); const tabList = document.querySelectorAll<HTMLElement>('tp-yt-paper-tab');
const tabs = { const tabs = {
upNext: tabList[0], upNext: tabList[0],
lyrics: tabList[1], lyrics: tabList[1],
discover: tabList[2], discover: tabList[2],
}; };
// Check if disabled // Check if disabled
if (!tabs.lyrics?.hasAttribute('disabled')) return; if (!tabs.lyrics?.hasAttribute('disabled')) return;
const lyrics = await invoke<string | null>( const lyrics = await invoke(
'search-genius-lyrics', 'search-genius-lyrics',
extractedSongInfo, extractedSongInfo,
) as string | null;
if (!lyrics) {
// Delete previous lyrics if tab is open and couldn't get new lyrics
tabs.upNext.click();
return;
}
if (window.electronIs.dev()) {
console.log('Fetched lyrics from Genius');
}
const tryToInjectLyric = (callback?: () => void) => {
const lyricsContainer = document.querySelector(
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
); );
if (!lyrics) { if (lyricsContainer) {
// Delete previous lyrics if tab is open and couldn't get new lyrics callback?.();
tabs.upNext.click();
return; setLyrics(lyricsContainer, lyrics);
applyLyricsTabState();
} }
};
if (window.electronIs.dev()) { const applyLyricsTabState = () => {
console.log('Fetched lyrics from Genius'); if (lyrics) {
tabs.lyrics.removeAttribute('disabled');
tabs.lyrics.removeAttribute('aria-disabled');
} else {
tabs.lyrics.setAttribute('disabled', '');
tabs.lyrics.setAttribute('aria-disabled', '');
} }
};
const lyricsTabHandler = () => {
const tabContainer = document.querySelector('ytmusic-tab-renderer');
if (!tabContainer) return;
const tryToInjectLyric = (callback?: () => void) => { const observer = new MutationObserver((_, observer) => {
const lyricsContainer = document.querySelector( tryToInjectLyric(() => observer.disconnect());
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer', });
);
if (lyricsContainer) { observer.observe(tabContainer, {
callback?.(); attributes: true,
childList: true,
subtree: true,
});
};
setLyrics(lyricsContainer, lyrics); applyLyricsTabState();
applyLyricsTabState();
}
};
const applyLyricsTabState = () => {
if (lyrics) {
tabs.lyrics.removeAttribute('disabled');
tabs.lyrics.removeAttribute('aria-disabled');
} else {
tabs.lyrics.setAttribute('disabled', '');
tabs.lyrics.setAttribute('aria-disabled', '');
}
};
const lyricsTabHandler = () => {
const tabContainer = document.querySelector('ytmusic-tab-renderer');
if (!tabContainer) return;
const observer = new MutationObserver((_, observer) => { tabs.discover.addEventListener('click', applyLyricsTabState);
tryToInjectLyric(() => observer.disconnect()); tabs.lyrics.addEventListener('click', lyricsTabHandler);
}); tabs.upNext.addEventListener('click', applyLyricsTabState);
observer.observe(tabContainer, { tryToInjectLyric();
attributes: true,
childList: true,
subtree: true,
});
};
applyLyricsTabState(); unregister = () => {
tabs.discover.removeEventListener('click', applyLyricsTabState);
tabs.discover.addEventListener('click', applyLyricsTabState); tabs.lyrics.removeEventListener('click', lyricsTabHandler);
tabs.lyrics.addEventListener('click', lyricsTabHandler); tabs.upNext.removeEventListener('click', applyLyricsTabState);
tabs.upNext.addEventListener('click', applyLyricsTabState); };
}, 500);
tryToInjectLyric(); });
};
unregister = () => {
tabs.discover.removeEventListener('click', applyLyricsTabState);
tabs.lyrics.removeEventListener('click', lyricsTabHandler);
tabs.upNext.removeEventListener('click', applyLyricsTabState);
};
}, 500);
});
}
}));

View File

@ -1,20 +1,24 @@
import style from './style.css?inline'; import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { createPluginBuilder } from '../utils/builder'; import forwardHTML from './templates/forward.html?raw';
import backHTML from './templates/back.html?raw';
const builder = createPluginBuilder('navigation', { export default createPlugin({
name: 'Navigation', name: 'Navigation',
restartNeeded: true, restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },
styles: [style], stylesheets: [style],
renderer() {
const forwardButton = ElementFromHtml(forwardHTML);
const backButton = ElementFromHtml(backHTML);
const menu = document.querySelector('#right-content');
if (menu) {
menu.prepend(backButton, forwardButton);
}
},
}); });
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,20 +0,0 @@
import forwardHTML from './templates/forward.html?raw';
import backHTML from './templates/back.html?raw';
import builder from './index';
import { ElementFromHtml } from '../utils/renderer';
export default builder.createRenderer(() => {
return {
onLoad() {
const forwardButton = ElementFromHtml(forwardHTML);
const backButton = ElementFromHtml(backHTML);
const menu = document.querySelector('#right-content');
if (menu) {
menu.prepend(backButton, forwardButton);
}
}
};
});

View File

@ -1,20 +1,46 @@
import style from './style.css?inline'; import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { createPluginBuilder } from '../utils/builder'; export default createPlugin({
const builder = createPluginBuilder('no-google-login', {
name: 'Remove Google Login', name: 'Remove Google Login',
restartNeeded: true, restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },
styles: [style], stylesheets: [style],
}); renderer() {
const elementsToRemove = [
'.sign-in-link.ytmusic-nav-bar',
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
];
export default builder; for (const selector of elementsToRemove) {
const node = document.querySelector(selector);
if (node) {
node.remove();
}
}
declare global { // Remove the library button
interface PluginBuilderList { const libraryIconPath
[builder.id]: typeof builder; = 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
const observer = new MutationObserver(() => {
const menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer',
);
menuEntries.forEach((item) => {
const icon = item.querySelector('path');
if (icon) {
observer.disconnect();
if (icon.getAttribute('d') === libraryIconPath) {
item.remove();
}
}
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
} }
} });

View File

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

View File

@ -1,39 +0,0 @@
import builder from './index';
export default builder.createRenderer(() => ({
onLoad() {
const elementsToRemove = [
'.sign-in-link.ytmusic-nav-bar',
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
];
for (const selector of elementsToRemove) {
const node = document.querySelector(selector);
if (node) {
node.remove();
}
}
// Remove the library button
const libraryIconPath
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
const observer = new MutationObserver(() => {
const menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer',
);
menuEntries.forEach((item) => {
const icon = item.querySelector('path');
if (icon) {
observer.disconnect();
if (icon.getAttribute('d') === libraryIconPath) {
item.remove();
}
}
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
}));

View File

@ -1,36 +1,46 @@
import { createPluginBuilder } from '../utils/builder'; import { createPlugin } from '@/utils';
import { onConfigChange, onMainLoad } from './main';
import { onMenu } from './menu';
export interface NotificationsPluginConfig { export interface NotificationsPluginConfig {
enabled: boolean; enabled: boolean;
unpauseNotification: boolean; unpauseNotification: boolean;
/**
* Has effect only on Linux
*/
urgency: 'low' | 'normal' | 'critical'; urgency: 'low' | 'normal' | 'critical';
/**
* the following has effect only on Windows
*/
interactive: boolean; interactive: boolean;
/**
* See plugins/notifications/utils for more info
*/
toastStyle: number; toastStyle: number;
refreshOnPlayPause: boolean; refreshOnPlayPause: boolean;
trayControls: boolean; trayControls: boolean;
hideButtonText: boolean; hideButtonText: boolean;
} }
const builder = createPluginBuilder('notifications', { export const defaultConfig: NotificationsPluginConfig = {
enabled: false,
unpauseNotification: false,
urgency: 'normal',
interactive: true,
toastStyle: 1,
refreshOnPlayPause: false,
trayControls: true,
hideButtonText: false,
};
export default createPlugin({
name: 'Notifications', name: 'Notifications',
restartNeeded: true, restartNeeded: true,
config: { config: defaultConfig,
enabled: false, menu: onMenu,
unpauseNotification: false, backend: {
urgency: 'normal', // Has effect only on Linux start: onMainLoad,
// the following has effect only on Windows onConfigChange,
interactive: true, },
toastStyle: 1, // See plugins/notifications/utils for more info
refreshOnPlayPause: false,
trayControls: true,
hideButtonText: false,
} as NotificationsPluginConfig,
}); });
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,21 +1,20 @@
import { app, BrowserWindow, Notification } from 'electron'; import { app, BrowserWindow, Notification } from 'electron';
import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
import nextIcon from '@assets/media-icons-black/next.png?asset&asarUnpack';
import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpack';
import { notificationImage, secondsToMinutes, ToastStyles } from './utils'; import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
import getSongControls from '../../providers/song-controls'; import getSongControls from '@/providers/song-controls';
import registerCallback, { SongInfo } from '../../providers/song-info'; import registerCallback, { SongInfo } from '@/providers/song-info';
import { changeProtocolHandler } from '../../providers/protocol-handler'; import { changeProtocolHandler } from '@/providers/protocol-handler';
import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray'; import { setTrayOnClick, setTrayOnDoubleClick } from '@/tray';
import { mediaIcons } from '../../types/media-icons'; import { mediaIcons } from '@/types/media-icons';
import playIcon from '../../../assets/media-icons-black/play.png?asset&asarUnpack';
import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnpack';
import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack';
import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack';
import { MainPluginContext } from '../utils/builder';
import type { NotificationsPluginConfig } from './index'; import type { NotificationsPluginConfig } from './index';
import type { BackendContext } from '@/types/contexts';
let songControls: ReturnType<typeof getSongControls>; let songControls: ReturnType<typeof getSongControls>;
let savedNotification: Notification | undefined; let savedNotification: Notification | undefined;
@ -25,7 +24,7 @@ type Accessor<T> = () => T;
export default ( export default (
win: BrowserWindow, win: BrowserWindow,
config: Accessor<NotificationsPluginConfig>, config: Accessor<NotificationsPluginConfig>,
{ on, send }: MainPluginContext<NotificationsPluginConfig>, { ipc: { on, send } }: BackendContext<NotificationsPluginConfig>,
) => { ) => {
const sendNotification = (songInfo: SongInfo) => { const sendNotification = (songInfo: SongInfo) => {
const iconSrc = notificationImage(songInfo, config()); const iconSrc = notificationImage(songInfo, config());

View File

@ -5,11 +5,12 @@ import is from 'electron-is';
import { notificationImage } from './utils'; import { notificationImage } from './utils';
import interactive from './interactive'; import interactive from './interactive';
import builder, { NotificationsPluginConfig } from './index'; import { defaultConfig, type NotificationsPluginConfig } from './index';
import registerCallback, { type SongInfo } from '@/providers/song-info';
import registerCallback, { SongInfo } from '../../providers/song-info'; import type { BackendContext } from '@/types/contexts';
let config: NotificationsPluginConfig = builder.config; let config: NotificationsPluginConfig = defaultConfig;
const notify = (info: SongInfo) => { const notify = (info: SongInfo) => {
// Send the notification // Send the notification
@ -42,17 +43,14 @@ const setup = () => {
}); });
}; };
export default builder.createMain((context) => { export const onMainLoad = async (context: BackendContext<NotificationsPluginConfig>) => {
return { config = await context.getConfig();
async onLoad(win) {
config = await context.getConfig();
// Register the callback for new song information // Register the callback for new song information
if (is.windows() && config.interactive) interactive(win, () => config, context); if (is.windows() && config.interactive) interactive(context.window, () => config, context);
else setup(); else setup();
}, };
onConfigChange(newConfig) {
config = newConfig; export const onConfigChange = (newConfig: NotificationsPluginConfig) => {
} config = newConfig;
}; };
});

View File

@ -1,14 +1,14 @@
import is from 'electron-is'; import is from 'electron-is';
import { MenuItem } from 'electron'; import { MenuItem } from 'electron';
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils'; import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
import builder, { NotificationsPluginConfig } from './index'; import type { NotificationsPluginConfig } from './index';
import type { MenuTemplate } from '../../menu'; import type { MenuTemplate } from '@/menu';
import type { MenuContext } from '@/types/contexts';
export default builder.createMenu(async ({ getConfig, setConfig }) => { export const onMenu = async ({ getConfig, setConfig }: MenuContext<NotificationsPluginConfig>): Promise<MenuTemplate> => {
const config = await getConfig(); const config = await getConfig();
const getToastStyleMenuItems = (options: NotificationsPluginConfig) => { const getToastStyleMenuItems = (options: NotificationsPluginConfig) => {
@ -25,7 +25,7 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
} }
return array as Electron.MenuItemConstructorOptions[]; return array as Electron.MenuItemConstructorOptions[];
} };
const getMenu = (): MenuTemplate => { const getMenu = (): MenuTemplate => {
if (is.linux()) { if (is.linux()) {
@ -92,4 +92,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
click: (item) => setConfig({ unpauseNotification: item.checked }), click: (item) => setConfig({ unpauseNotification: item.checked }),
}, },
]; ];
}); };

View File

@ -3,12 +3,12 @@ import fs from 'node:fs';
import { app, NativeImage } from 'electron'; import { app, NativeImage } from 'electron';
import { cache } from '../../providers/decorators'; import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack';
import { SongInfo } from '../../providers/song-info';
import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack'; import { cache } from '@/providers/decorators';
import {NotificationsPluginConfig} from "./index"; import { SongInfo } from '@/providers/song-info';
import type { NotificationsPluginConfig } from './index';
const userData = app.getPath('userData'); const userData = app.getPath('userData');
const temporaryIcon = path.join(userData, 'tempIcon.png'); const temporaryIcon = path.join(userData, 'tempIcon.png');

View File

@ -1,6 +1,9 @@
import style from './style.css?inline'; import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { createPluginBuilder } from '../utils/builder'; import { onConfigChange, onMainLoad } from './main';
import { onMenu } from './menu';
import { onPlayerApiReady, onRendererLoad } from './renderer';
export type PictureInPicturePluginConfig = { export type PictureInPicturePluginConfig = {
'enabled': boolean; 'enabled': boolean;
@ -14,7 +17,7 @@ export type PictureInPicturePluginConfig = {
'useNativePiP': boolean; 'useNativePiP': boolean;
} }
const builder = createPluginBuilder('picture-in-picture', { export default createPlugin({
name: 'Picture In Picture', name: 'Picture In Picture',
restartNeeded: true, restartNeeded: true,
config: { config: {
@ -28,13 +31,15 @@ const builder = createPluginBuilder('picture-in-picture', {
'isInPiP': false, 'isInPiP': false,
'useNativePiP': true, 'useNativePiP': true,
} as PictureInPicturePluginConfig, } as PictureInPicturePluginConfig,
styles: [style], stylesheets: [style],
}); menu: onMenu,
export default builder; backend: {
start: onMainLoad,
declare global { onConfigChange,
interface PluginBuilderList { },
[builder.id]: typeof builder; renderer: {
start: onRendererLoad,
onPlayerApiReady,
} }
} });

View File

@ -1,22 +1,18 @@
import { app, BrowserWindow, ipcMain } from 'electron'; import { app } from 'electron';
import style from './style.css?inline'; import type { PictureInPicturePluginConfig } from './index';
import builder, { PictureInPicturePluginConfig } from './index'; import type { BackendContext } from '@/types/contexts';
import { injectCSS } from '../utils/main'; let config: PictureInPicturePluginConfig;
export default builder.createMain(({ getConfig, setConfig, send, handle, on }) => { export const onMainLoad = async ({ window, getConfig, setConfig, ipc: { send, handle, on } }: BackendContext<PictureInPicturePluginConfig>) => {
let isInPiP = false; let isInPiP = false;
let originalPosition: number[]; let originalPosition: number[];
let originalSize: number[]; let originalSize: number[];
let originalFullScreen: boolean; let originalFullScreen: boolean;
let originalMaximized: boolean; let originalMaximized: boolean;
let win: BrowserWindow;
let config: PictureInPicturePluginConfig;
const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10]; const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10];
const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275]; const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275];
@ -25,59 +21,59 @@ export default builder.createMain(({ getConfig, setConfig, send, handle, on }) =
setConfig({ isInPiP }); setConfig({ isInPiP });
if (isInPiP) { if (isInPiP) {
originalFullScreen = win.isFullScreen(); originalFullScreen = window.isFullScreen();
if (originalFullScreen) { if (originalFullScreen) {
win.setFullScreen(false); window.setFullScreen(false);
} }
originalMaximized = win.isMaximized(); originalMaximized = window.isMaximized();
if (originalMaximized) { if (originalMaximized) {
win.unmaximize(); window.unmaximize();
} }
originalPosition = win.getPosition(); originalPosition = window.getPosition();
originalSize = win.getSize(); originalSize = window.getSize();
handle('before-input-event', blockShortcutsInPiP); handle('before-input-event', blockShortcutsInPiP);
win.setMaximizable(false); window.setMaximizable(false);
win.setFullScreenable(false); window.setFullScreenable(false);
send('pip-toggle', true); send('pip-toggle', true);
app.dock?.hide(); app.dock?.hide();
win.setVisibleOnAllWorkspaces(true, { window.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true, visibleOnFullScreen: true,
}); });
app.dock?.show(); app.dock?.show();
if (config.alwaysOnTop) { if (config.alwaysOnTop) {
win.setAlwaysOnTop(true, 'screen-saver', 1); window.setAlwaysOnTop(true, 'screen-saver', 1);
} }
} else { } else {
win.webContents.removeListener('before-input-event', blockShortcutsInPiP); window.webContents.removeListener('before-input-event', blockShortcutsInPiP);
win.setMaximizable(true); window.setMaximizable(true);
win.setFullScreenable(true); window.setFullScreenable(true);
send('pip-toggle', false); send('pip-toggle', false);
win.setVisibleOnAllWorkspaces(false); window.setVisibleOnAllWorkspaces(false);
win.setAlwaysOnTop(false); window.setAlwaysOnTop(false);
if (originalFullScreen) { if (originalFullScreen) {
win.setFullScreen(true); window.setFullScreen(true);
} }
if (originalMaximized) { if (originalMaximized) {
win.maximize(); window.maximize();
} }
} }
const [x, y] = isInPiP ? pipPosition() : originalPosition; const [x, y] = isInPiP ? pipPosition() : originalPosition;
const [w, h] = isInPiP ? pipSize() : originalSize; const [w, h] = isInPiP ? pipSize() : originalSize;
win.setPosition(x, y); window.setPosition(x, y);
win.setSize(w, h); window.setSize(w, h);
win.setWindowButtonVisibility?.(!isInPiP); window.setWindowButtonVisibility?.(!isInPiP);
}; };
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => { const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
@ -91,30 +87,25 @@ export default builder.createMain(({ getConfig, setConfig, send, handle, on }) =
} }
}; };
return ({ config ??= await getConfig();
async onLoad(window) { setConfig({ isInPiP });
config ??= await getConfig(); on('picture-in-picture', () => {
win ??= window; togglePiP();
setConfig({ isInPiP }); });
on('picture-in-picture', () => {
togglePiP();
});
window.on('move', () => { window.on('move', () => {
if (config.isInPiP && !config.useNativePiP) { if (config.isInPiP && !config.useNativePiP) {
setConfig({ 'pip-position': window.getPosition() as [number, number] }); setConfig({ 'pip-position': window.getPosition() as [number, number] });
}
});
window.on('resize', () => {
if (config.isInPiP && !config.useNativePiP) {
setConfig({ 'pip-size': window.getSize() as [number, number] });
}
});
},
onConfigChange(newConfig) {
config = newConfig;
} }
}); });
});
window.on('resize', () => {
if (config.isInPiP && !config.useNativePiP) {
setConfig({ 'pip-size': window.getSize() as [number, number] });
}
});
};
export const onConfigChange = (newConfig: PictureInPicturePluginConfig) => {
config = newConfig;
};

View File

@ -1,11 +1,14 @@
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import builder from './index'; import promptOptions from '@/providers/prompt-options';
import promptOptions from '../../providers/prompt-options'; import type { PictureInPicturePluginConfig } from './index';
import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu';
export default builder.createMenu(async ({ window, getConfig, setConfig }) => { export const onMenu = async ({ window, getConfig, setConfig }: MenuContext<PictureInPicturePluginConfig>): Promise<MenuTemplate> => {
const config = await getConfig(); const config = await getConfig();
return [ return [
@ -71,4 +74,4 @@ export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
}, },
}, },
]; ];
}); };

View File

@ -3,12 +3,13 @@ import keyEventAreEqual from 'keyboardevents-areequal';
import pipHTML from './templates/picture-in-picture.html?raw'; import pipHTML from './templates/picture-in-picture.html?raw';
import builder, { PictureInPicturePluginConfig } from './index'; import { getSongMenu } from '@/providers/dom-elements';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils/renderer'; import { ElementFromHtml } from '../utils/renderer';
import type { PictureInPicturePluginConfig } from './index';
import type { RendererContext } from '@/types/contexts';
function $<E extends Element = Element>(selector: string) { function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector); return document.querySelector<E>(selector);
} }
@ -133,42 +134,38 @@ const listenForToggle = () => {
}); });
}; };
export const onRendererLoad = async ({ getConfig }: RendererContext<PictureInPicturePluginConfig>) => {
const config = await getConfig();
export default builder.createRenderer(({ getConfig }) => { useNativePiP = config.useNativePiP;
return {
async onLoad() {
const config = await getConfig();
useNativePiP = config.useNativePiP; if (config.hotkey) {
const hotkeyEvent = toKeyEvent(config.hotkey);
if (config.hotkey) { window.addEventListener('keydown', (event) => {
const hotkeyEvent = toKeyEvent(config.hotkey); if (
window.addEventListener('keydown', (event) => { keyEventAreEqual(event, hotkeyEvent)
if ( && !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
keyEventAreEqual(event, hotkeyEvent) ) {
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened togglePictureInPicture();
) {
togglePictureInPicture();
}
});
} }
}, });
onPlayerApiReady() { }
listenForToggle(); };
cloneButton('.player-minimize-button')?.addEventListener('click', async () => { export const onPlayerApiReady = () => {
await togglePictureInPicture(); listenForToggle();
setTimeout(() => $<HTMLButtonElement>('#player')?.click());
});
// Allows easily closing the menu by programmatically clicking outside of it cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
$('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click'); await togglePictureInPicture();
// TODO: think about wether an additional button in songMenu is needed setTimeout(() => $<HTMLButtonElement>('#player')?.click());
const popupContainer = $('ytmusic-popup-container'); });
if (popupContainer) observer.observe(popupContainer, {
childList: true, // Allows easily closing the menu by programmatically clicking outside of it
subtree: true, $('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click');
}); // TODO: think about wether an additional button in songMenu is needed
}, const popupContainer = $('ytmusic-popup-container');
}; if (popupContainer) observer.observe(popupContainer, {
}); childList: true,
subtree: true,
});
};

View File

@ -1,17 +1,14 @@
import { createPluginBuilder } from '../utils/builder'; import { createPlugin } from '@/utils';
import { onPlayerApiReady, onUnload } from './renderer';
const builder = createPluginBuilder('playback-speed', { export default createPlugin({
name: 'Playback Speed', name: 'Playback Speed',
restartNeeded: false, restartNeeded: false,
config: { config: {
enabled: false, enabled: false,
}, },
}); renderer: {
stop: onUnload,
export default builder; onPlayerApiReady,
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
} }
} });

View File

@ -1,15 +1,9 @@
import sliderHTML from './templates/slider.html?raw'; import sliderHTML from './templates/slider.html?raw';
import builder from './index'; import { getSongMenu } from '@/providers/dom-elements';
import { singleton } from '@/providers/decorators';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils/renderer'; import { ElementFromHtml } from '../utils/renderer';
import { singleton } from '../../providers/decorators';
function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector);
}
const slider = ElementFromHtml(sliderHTML); const slider = ElementFromHtml(sliderHTML);
@ -21,12 +15,12 @@ const MAX_PLAYBACK_SPEED = 16;
let playbackSpeed = 1; let playbackSpeed = 1;
const updatePlayBackSpeed = () => { const updatePlayBackSpeed = () => {
const videoElement = $<HTMLVideoElement>('video'); const videoElement = document.querySelector<HTMLVideoElement>('video');
if (videoElement) { if (videoElement) {
videoElement.playbackRate = playbackSpeed; videoElement.playbackRate = playbackSpeed;
} }
const playbackSpeedElement = $('#playback-speed-value'); const playbackSpeedElement = document.querySelector('#playback-speed-value');
if (playbackSpeedElement) { if (playbackSpeedElement) {
playbackSpeedElement.innerHTML = String(playbackSpeed); playbackSpeedElement.innerHTML = String(playbackSpeed);
} }
@ -44,7 +38,7 @@ const immediateValueChangedListener = (e: Event) => {
}; };
const setupSliderListener = singleton(() => { const setupSliderListener = singleton(() => {
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener); document.querySelector('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener);
}); });
const observePopupContainer = () => { const observePopupContainer = () => {
@ -64,7 +58,7 @@ const observePopupContainer = () => {
} }
}); });
const popupContainer = $('ytmusic-popup-container'); const popupContainer = document.querySelector('ytmusic-popup-container');
if (popupContainer) { if (popupContainer) {
observer.observe(popupContainer, { observer.observe(popupContainer, {
childList: true, childList: true,
@ -74,7 +68,7 @@ const observePopupContainer = () => {
}; };
const observeVideo = () => { const observeVideo = () => {
const video = $<HTMLVideoElement>('video'); const video = document.querySelector<HTMLVideoElement>('video');
if (video) { if (video) {
video.addEventListener('ratechange', forcePlaybackRate); video.addEventListener('ratechange', forcePlaybackRate);
video.addEventListener('srcChanged', forcePlaybackRate); video.addEventListener('srcChanged', forcePlaybackRate);
@ -95,7 +89,7 @@ const wheelEventListener = (e: WheelEvent) => {
updatePlayBackSpeed(); updatePlayBackSpeed();
// Update slider position // Update slider position
const playbackSpeedSilder = $<HTMLElement & { value: number }>('#playback-speed-slider'); const playbackSpeedSilder = document.querySelector<HTMLElement & { value: number }>('#playback-speed-slider');
if (playbackSpeedSilder) { if (playbackSpeedSilder) {
playbackSpeedSilder.value = playbackSpeed; playbackSpeedSilder.value = playbackSpeed;
} }
@ -114,22 +108,19 @@ function forcePlaybackRate(e: Event) {
} }
} }
export default builder.createRenderer(() => { export const onPlayerApiReady = () => {
return { observePopupContainer();
onPlayerApiReady() { observeVideo();
observePopupContainer(); setupWheelListener();
observeVideo(); };
setupWheelListener();
}, export const onUnload = () => {
onUnload() { const video = document.querySelector<HTMLVideoElement>('video');
const video = $<HTMLVideoElement>('video'); if (video) {
if (video) { video.removeEventListener('ratechange', forcePlaybackRate);
video.removeEventListener('ratechange', forcePlaybackRate); video.removeEventListener('srcChanged', forcePlaybackRate);
video.removeEventListener('srcChanged', forcePlaybackRate); }
} slider.removeEventListener('wheel', wheelEventListener);
slider.removeEventListener('wheel', wheelEventListener); getSongMenu()?.removeChild(slider);
getSongMenu()?.removeChild(slider); document.querySelector('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener);
$('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener); };
}
};
});

View File

@ -1,6 +1,12 @@
import hudStyle from './volume-hud.css?inline'; import { globalShortcut, MenuItem } from 'electron';
import prompt, { KeybindOptions } from 'custom-electron-prompt';
import { createPluginBuilder } from '../utils/builder'; import hudStyle from './volume-hud.css?inline';
import { createPlugin } from '@/utils';
import promptOptions from '@/providers/prompt-options';
import { overrideListener } from './override';
import { onConfigChange, onPlayerApiReady } from './renderer';
export type PreciseVolumePluginConfig = { export type PreciseVolumePluginConfig = {
enabled: boolean; enabled: boolean;
@ -13,7 +19,7 @@ export type PreciseVolumePluginConfig = {
savedVolume: number | undefined; savedVolume: number | undefined;
}; };
const builder = createPluginBuilder('precise-volume', { export default createPlugin({
name: 'Precise Volume', name: 'Precise Volume',
restartNeeded: true, restartNeeded: true,
config: { config: {
@ -26,13 +32,107 @@ const builder = createPluginBuilder('precise-volume', {
}, },
savedVolume: undefined, // Plugin save volume between session here savedVolume: undefined, // Plugin save volume between session here
} as PreciseVolumePluginConfig, } as PreciseVolumePluginConfig,
styles: [hudStyle], stylesheets: [hudStyle],
}); menu: async ({ setConfig, getConfig, window }) => {
const config = await getConfig();
export default builder; function changeOptions(changedOptions: Partial<PreciseVolumePluginConfig>, options: PreciseVolumePluginConfig) {
for (const option in changedOptions) {
// HACK: Weird TypeScript error
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
}
declare global { setConfig(options);
interface PluginBuilderList { }
[builder.id]: typeof builder;
// Helper function for globalShortcuts prompt
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined });
async function promptVolumeSteps(options: PreciseVolumePluginConfig) {
const output = await prompt({
title: 'Volume Steps',
label: 'Choose Volume Increase/Decrease Steps',
value: options.steps || 1,
type: 'counter',
counterOptions: { minimum: 0, maximum: 100, multiFire: true },
width: 380,
...promptOptions(),
}, window);
if (output || output === 0) { // 0 is somewhat valid
changeOptions({ steps: output }, options);
}
}
async function promptGlobalShortcuts(options: PreciseVolumePluginConfig, item: MenuItem) {
const output = await prompt({
title: 'Global Volume Keybinds',
label: 'Choose Global Volume Keybinds:',
type: 'keybind',
keybindOptions: [
kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp),
kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown),
],
...promptOptions(),
}, window);
if (output) {
const newGlobalShortcuts: {
volumeUp: string;
volumeDown: string;
} = { volumeUp: '', volumeDown: '' };
for (const { value, accelerator } of output) {
newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator;
}
changeOptions({ globalShortcuts: newGlobalShortcuts }, options);
item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown);
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
}
return [
{
label: 'Local Arrowkeys Controls',
type: 'checkbox',
checked: Boolean(config.arrowsShortcut),
click(item) {
changeOptions({ arrowsShortcut: item.checked }, config);
},
},
{
label: 'Global Hotkeys',
type: 'checkbox',
checked: Boolean(config.globalShortcuts?.volumeUp ?? config.globalShortcuts?.volumeDown),
click: (item) => promptGlobalShortcuts(config, item),
},
{
label: 'Set Custom Volume Steps',
click: () => promptVolumeSteps(config),
},
];
},
async backend({ getConfig, ipc }) {
const config = await getConfig();
if (config.globalShortcuts?.volumeUp) {
globalShortcut.register(config.globalShortcuts.volumeUp, () => ipc.send('changeVolume', true));
}
if (config.globalShortcuts?.volumeDown) {
globalShortcut.register(config.globalShortcuts.volumeDown, () => ipc.send('changeVolume', false));
}
},
renderer: {
start() {
overrideListener();
},
onPlayerApiReady,
onConfigChange,
} }
} });

View File

@ -1,17 +0,0 @@
import { globalShortcut } from 'electron';
import builder from './index';
export default builder.createMain(({ getConfig, send }) => ({
async onLoad() {
const config = await getConfig();
if (config.globalShortcuts?.volumeUp) {
globalShortcut.register(config.globalShortcuts.volumeUp, () => send('changeVolume', true));
}
if (config.globalShortcuts?.volumeDown) {
globalShortcut.register(config.globalShortcuts.volumeDown, () => send('changeVolume', false));
}
},
}));

View File

@ -1,90 +0,0 @@
import prompt, { KeybindOptions } from 'custom-electron-prompt';
import { BrowserWindow, MenuItem } from 'electron';
import builder, { PreciseVolumePluginConfig } from './index';
import promptOptions from '../../providers/prompt-options';
export default builder.createMenu(async ({ setConfig, getConfig, window }) => {
const config = await getConfig();
function changeOptions(changedOptions: Partial<PreciseVolumePluginConfig>, options: PreciseVolumePluginConfig, win: BrowserWindow) {
for (const option in changedOptions) {
// HACK: Weird TypeScript error
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
}
setConfig(options);
}
// Helper function for globalShortcuts prompt
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined });
async function promptVolumeSteps(win: BrowserWindow, options: PreciseVolumePluginConfig) {
const output = await prompt({
title: 'Volume Steps',
label: 'Choose Volume Increase/Decrease Steps',
value: options.steps || 1,
type: 'counter',
counterOptions: { minimum: 0, maximum: 100, multiFire: true },
width: 380,
...promptOptions(),
}, win);
if (output || output === 0) { // 0 is somewhat valid
changeOptions({ steps: output }, options, win);
}
}
async function promptGlobalShortcuts(win: BrowserWindow, options: PreciseVolumePluginConfig, item: MenuItem) {
const output = await prompt({
title: 'Global Volume Keybinds',
label: 'Choose Global Volume Keybinds:',
type: 'keybind',
keybindOptions: [
kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp),
kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown),
],
...promptOptions(),
}, win);
if (output) {
const newGlobalShortcuts: {
volumeUp: string;
volumeDown: string;
} = { volumeUp: '', volumeDown: '' };
for (const { value, accelerator } of output) {
newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator;
}
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win);
item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown);
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;
}
}
return [
{
label: 'Local Arrowkeys Controls',
type: 'checkbox',
checked: Boolean(config.arrowsShortcut),
click(item) {
changeOptions({ arrowsShortcut: item.checked }, config, window);
},
},
{
label: 'Global Hotkeys',
type: 'checkbox',
checked: Boolean(config.globalShortcuts?.volumeUp ?? config.globalShortcuts?.volumeDown),
click: (item) => promptGlobalShortcuts(window, config, item),
},
{
label: 'Set Custom Volume Steps',
click: () => promptVolumeSteps(window, config),
},
];
});

View File

@ -1,5 +1,4 @@
/* what */ /* what */
/* eslint-disable @typescript-eslint/ban-ts-comment */
const ignored = { const ignored = {
id: ['volume-slider', 'expand-volume-slider'], id: ['volume-slider', 'expand-volume-slider'],
@ -9,7 +8,8 @@ const ignored = {
function overrideAddEventListener() { function overrideAddEventListener() {
// YO WHAT ARE YOU DOING NOW?!?! // YO WHAT ARE YOU DOING NOW?!?!
// Save native addEventListener // Save native addEventListener
// @ts-ignore // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error - We know what we're doing
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
Element.prototype._addEventListener = Element.prototype.addEventListener; Element.prototype._addEventListener = Element.prototype.addEventListener;
// Override addEventListener to Ignore specific events in volume-slider // Override addEventListener to Ignore specific events in volume-slider

View File

@ -1,10 +1,9 @@
import { overrideListener } from './override'; import { type PreciseVolumePluginConfig } from './index';
import builder, { type PreciseVolumePluginConfig } from './index'; import { debounce } from '@/providers/decorators';
import { debounce } from '../../providers/decorators'; import type { RendererContext } from '@/types/contexts';
import type { YoutubePlayer } from '@/types/youtube-player';
import type { YoutubePlayer } from '../../types/youtube-player';
function $<E extends Element = Element>(selector: string) { function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector); return document.querySelector<E>(selector);
@ -23,12 +22,14 @@ export const moveVolumeHud = debounce((showVideo: boolean) => {
: '0'; : '0';
}, 250); }, 250);
export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { let options: PreciseVolumePluginConfig;
let options: PreciseVolumePluginConfig = await getConfig();
export const onPlayerApiReady = (playerApi: YoutubePlayer, context: RendererContext<PreciseVolumePluginConfig>) => {
api = playerApi;
// Without this function it would rewrite config 20 time when volume change by 20 // Without this function it would rewrite config 20 time when volume change by 20
const writeOptions = debounce(() => { const writeOptions = debounce(() => {
setConfig(options); context.setConfig(options);
}, 1000); }, 1000);
const hideVolumeHud = debounce((volumeHud: HTMLElement) => { const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
@ -254,20 +255,12 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
} }
} }
context.ipc.on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease));
context.ipc.on('setVolume', (value: number) => setVolume(value));
return { firstRun();
onLoad() { };
overrideListener();
},
onPlayerApiReady(playerApi) {
api = playerApi;
on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease)); export const onConfigChange = (config: PreciseVolumePluginConfig) => {
on('setVolume', (value: number) => setVolume(value)); options = config;
firstRun(); };
},
onConfigChange(config) {
options = config;
}
};
});

View File

@ -1,17 +1,65 @@
import { createPluginBuilder } from '../utils/builder'; import { dialog } from 'electron';
const builder = createPluginBuilder('quality-changer', { import QualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
import { createPlugin } from '@/utils';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import type { YoutubePlayer } from '@/types/youtube-player';
export default createPlugin({
name: 'Video Quality Changer', name: 'Video Quality Changer',
restartNeeded: false, restartNeeded: false,
config: { config: {
enabled: false, enabled: false,
}, },
backend({ ipc, window }) {
ipc.handle('qualityChanger', async (qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(window, {
type: 'question',
buttons: qualityLabels,
defaultId: currentIndex,
title: 'Choose Video Quality',
message: 'Choose Video Quality:',
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
cancelId: -1,
}));
},
renderer: {
qualitySettingsButton: ElementFromHtml(QualitySettingsTemplate),
onPlayerApiReady(api: YoutubePlayer, context) {
const getPlayer = () => document.querySelector<HTMLVideoElement>('#player');
const chooseQuality = () => {
setTimeout(() => getPlayer()?.click());
const qualityLevels = api.getAvailableQualityLevels();
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
(context.ipc.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex) as Promise<{ response: number }>)
.then((promise) => {
if (promise.response === -1) {
return;
}
const newQuality = qualityLevels[promise.response];
api.setPlaybackQualityRange(newQuality);
api.setPlaybackQuality(newQuality);
});
};
const setup = () => {
document.querySelector('.top-row-buttons.ytmusic-player')?.prepend(this.qualitySettingsButton);
this.qualitySettingsButton.addEventListener('click', chooseQuality);
};
setup();
},
stop() {
document.querySelector('.top-row-buttons.ytmusic-player')?.removeChild(this.qualitySettingsButton);
},
}
}); });
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,17 +0,0 @@
import { dialog, BrowserWindow } from 'electron';
import builder from './index';
export default builder.createMain(({ handle }) => ({
onLoad(win: BrowserWindow) {
handle('qualityChanger', async (qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(win, {
type: 'question',
buttons: qualityLabels,
defaultId: currentIndex,
title: 'Choose Video Quality',
message: 'Choose Video Quality:',
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
cancelId: -1,
}));
}
}));

View File

@ -1,54 +0,0 @@
import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
import builder from './index';
import { ElementFromHtml } from '../utils/renderer';
import type { YoutubePlayer } from '../../types/youtube-player';
export default builder.createRenderer(({ invoke }) => {
function $(selector: string): HTMLElement | null {
return document.querySelector(selector);
}
const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate);
let api: YoutubePlayer;
const chooseQuality = () => {
setTimeout(() => $('#player')?.click());
const qualityLevels = api.getAvailableQualityLevels();
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
invoke<{ response: number }>('qualityChanger', api.getAvailableQualityLabels(), currentIndex)
.then((promise) => {
if (promise.response === -1) {
return;
}
const newQuality = qualityLevels[promise.response];
api.setPlaybackQualityRange(newQuality);
api.setPlaybackQuality(newQuality);
});
};
function setup() {
$('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton);
qualitySettingsButton.addEventListener('click', chooseQuality);
}
return {
onPlayerApiReady(playerApi) {
api = playerApi;
setup();
},
onUnload() {
$('.top-row-buttons.ytmusic-player')?.removeChild(qualitySettingsButton);
qualitySettingsButton.removeEventListener('click', chooseQuality);
}
};
});

View File

@ -1,4 +1,6 @@
import { createPluginBuilder } from '../utils/builder'; import { createPlugin } from '@/utils';
import { onMainLoad } from './main';
import { onMenu } from './menu';
export type ShortcutMappingType = { export type ShortcutMappingType = {
previous: string; previous: string;
@ -12,7 +14,7 @@ export type ShortcutsPluginConfig = {
local: ShortcutMappingType; local: ShortcutMappingType;
} }
const builder = createPluginBuilder('shortcuts', { export default createPlugin({
name: 'Shortcuts (& MPRIS)', name: 'Shortcuts (& MPRIS)',
restartNeeded: true, restartNeeded: true,
config: { config: {
@ -29,12 +31,7 @@ const builder = createPluginBuilder('shortcuts', {
next: '', next: '',
}, },
} as ShortcutsPluginConfig, } as ShortcutsPluginConfig,
menu: onMenu,
backend: onMainLoad,
}); });
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,12 +1,13 @@
import { BrowserWindow, globalShortcut } from 'electron'; import { BrowserWindow, globalShortcut } from 'electron';
import is from 'electron-is'; import is from 'electron-is';
import electronLocalshortcut from 'electron-localshortcut'; import { register as registerElectronLocalShortcut } from 'electron-localshortcut';
import registerMPRIS from './mpris'; import registerMPRIS from './mpris';
import getSongControls from '@/providers/song-controls';
import builder, { ShortcutMappingType } from './index'; import type { ShortcutMappingType, ShortcutsPluginConfig } from './index';
import getSongControls from '../../providers/song-controls'; import type { BackendContext } from '@/types/contexts';
function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) { function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) {
@ -16,62 +17,58 @@ function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: st
} }
function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) { function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) {
electronLocalshortcut.register(win, shortcut, () => { registerElectronLocalShortcut(win, shortcut, () => {
action(win.webContents); action(win.webContents);
}); });
} }
export default builder.createMain(({ getConfig }) => { export const onMainLoad = async ({ getConfig, window }: BackendContext<ShortcutsPluginConfig>) => {
return { const config = await getConfig();
async onLoad(win) {
const config = await getConfig();
const songControls = getSongControls(win); const songControls = getSongControls(window);
const { playPause, next, previous, search } = songControls; const { playPause, next, previous, search } = songControls;
if (config.overrideMediaKeys) { if (config.overrideMediaKeys) {
_registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause); _registerGlobalShortcut(window.webContents, 'MediaPlayPause', playPause);
_registerGlobalShortcut(win.webContents, 'MediaNextTrack', next); _registerGlobalShortcut(window.webContents, 'MediaNextTrack', next);
_registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous); _registerGlobalShortcut(window.webContents, 'MediaPreviousTrack', previous);
}
_registerLocalShortcut(window, 'CommandOrControl+F', search);
_registerLocalShortcut(window, 'CommandOrControl+L', search);
if (is.linux()) {
registerMPRIS(window);
}
const { global, local } = config;
const shortcutOptions = { global, local };
for (const optionType in shortcutOptions) {
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
}
function registerAllShortcuts(container: ShortcutMappingType, type: string) {
for (const _action in container) {
// HACK: _action is detected as string, but it's actually a key of ShortcutMappingType
const action = _action as keyof ShortcutMappingType;
if (!container[action]) {
continue; // Action accelerator is empty
} }
_registerLocalShortcut(win, 'CommandOrControl+F', search); console.debug(`Registering ${type} shortcut`, container[action], ':', action);
_registerLocalShortcut(win, 'CommandOrControl+L', search); const actionCallback: () => void = songControls[action];
if (typeof actionCallback !== 'function') {
if (is.linux()) { console.warn('Invalid action', action);
registerMPRIS(win); continue;
} }
const { global, local } = config; if (type === 'global') {
const shortcutOptions = { global, local }; _registerGlobalShortcut(window.webContents, container[action], actionCallback);
} else { // Type === "local"
for (const optionType in shortcutOptions) { _registerLocalShortcut(window, local[action], actionCallback);
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
}
function registerAllShortcuts(container: ShortcutMappingType, type: string) {
for (const _action in container) {
// HACK: _action is detected as string, but it's actually a key of ShortcutMappingType
const action = _action as keyof ShortcutMappingType;
if (!container[action]) {
continue; // Action accelerator is empty
}
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
const actionCallback: () => void = songControls[action];
if (typeof actionCallback !== 'function') {
console.warn('Invalid action', action);
continue;
}
if (type === 'global') {
_registerGlobalShortcut(win.webContents, container[action], actionCallback);
} else { // Type === "local"
_registerLocalShortcut(win, local[action], actionCallback);
}
}
} }
} }
}; }
}); };

View File

@ -1,12 +1,13 @@
import prompt, { KeybindOptions } from 'custom-electron-prompt'; import prompt, { KeybindOptions } from 'custom-electron-prompt';
import builder, { ShortcutsPluginConfig } from './index'; import promptOptions from '@/providers/prompt-options';
import promptOptions from '../../providers/prompt-options';
import type { ShortcutsPluginConfig } from './index';
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu';
export default builder.createMenu(async ({ window, getConfig, setConfig }) => { export const onMenu = async ({ window, getConfig, setConfig }: MenuContext<ShortcutsPluginConfig>): Promise<MenuTemplate> => {
const config = await getConfig(); const config = await getConfig();
/** /**
@ -52,4 +53,4 @@ export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
click: (item) => setConfig({ overrideMediaKeys: item.checked }), click: (item) => setConfig({ overrideMediaKeys: item.checked }),
}, },
]; ];
}); };

View File

@ -2,9 +2,9 @@ import { BrowserWindow, ipcMain } from 'electron';
import mpris, { Track } from '@jellybrick/mpris-service'; import mpris, { Track } from '@jellybrick/mpris-service';
import registerCallback from '../../providers/song-info'; import registerCallback from '@/providers/song-info';
import getSongControls from '../../providers/song-controls'; import getSongControls from '@/providers/song-controls';
import config from '../../config'; import config from '@/config';
function setupMPRIS() { function setupMPRIS() {
const instance = new mpris({ const instance = new mpris({

View File

@ -1,23 +1,20 @@
import { createPluginBuilder } from '../utils/builder'; import { createPlugin } from '@/utils';
import { onRendererLoad, onRendererUnload } from './renderer';
export type SkipSilencesPluginConfig = { export type SkipSilencesPluginConfig = {
enabled: boolean; enabled: boolean;
onlySkipBeginning: boolean; onlySkipBeginning: boolean;
}; };
const builder = createPluginBuilder('skip-silences', { export default createPlugin({
name: 'Skip Silences', name: 'Skip Silences',
restartNeeded: true, restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
onlySkipBeginning: false, onlySkipBeginning: false,
} as SkipSilencesPluginConfig, } as SkipSilencesPluginConfig,
}); renderer: {
start: onRendererLoad,
export default builder; stop: onRendererUnload,
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
} }
} });

View File

@ -1,140 +1,139 @@
import builder, { type SkipSilencesPluginConfig } from './index'; import { RendererContext } from '@/types/contexts';
export default builder.createRenderer(({ getConfig }) => { import type { SkipSilencesPluginConfig } from './index';
let config: SkipSilencesPluginConfig;
let isSilent = false; let config: SkipSilencesPluginConfig;
let hasAudioStarted = false;
const smoothing = 0.1; let isSilent = false;
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest) let hasAudioStarted = false;
const interval = 2; // Ms
const history = 10;
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
let playOrSeekHandler: (() => void) | undefined; const smoothing = 0.1;
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
const interval = 2; // Ms
const history = 10;
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => { let playOrSeekHandler: (() => void) | undefined;
let maxVolume = Number.NEGATIVE_INFINITY;
analyser.getFloatFrequencyData(fftBins);
for (let i = 4, ii = fftBins.length; i < ii; i++) { const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => {
if (fftBins[i] > maxVolume && fftBins[i] < 0) { let maxVolume = Number.NEGATIVE_INFINITY;
maxVolume = fftBins[i]; analyser.getFloatFrequencyData(fftBins);
}
for (let i = 4, ii = fftBins.length; i < ii; i++) {
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
maxVolume = fftBins[i];
} }
}
return maxVolume; return maxVolume;
}; };
const audioCanPlayListener = (e: CustomEvent<Compressor>) => { const audioCanPlayListener = (e: CustomEvent<Compressor>) => {
const video = document.querySelector('video'); const video = document.querySelector('video');
const { audioContext } = e.detail; const { audioContext } = e.detail;
const sourceNode = e.detail.audioSource; const sourceNode = e.detail.audioSource;
// Use an audio analyser similar to Hark // Use an audio analyser similar to Hark
// https://github.com/otalk/hark/blob/master/hark.bundle.js // https://github.com/otalk/hark/blob/master/hark.bundle.js
const analyser = audioContext.createAnalyser(); const analyser = audioContext.createAnalyser();
analyser.fftSize = 512; analyser.fftSize = 512;
analyser.smoothingTimeConstant = smoothing; analyser.smoothingTimeConstant = smoothing;
const fftBins = new Float32Array(analyser.frequencyBinCount); const fftBins = new Float32Array(analyser.frequencyBinCount);
sourceNode.connect(analyser); sourceNode.connect(analyser);
analyser.connect(audioContext.destination); analyser.connect(audioContext.destination);
const looper = () => { const looper = () => {
setTimeout(() => { setTimeout(() => {
const currentVolume = getMaxVolume(analyser, fftBins); const currentVolume = getMaxVolume(analyser, fftBins);
let history = 0; let history = 0;
if (currentVolume > threshold && isSilent) { if (currentVolume > threshold && isSilent) {
// Trigger quickly, short history // Trigger quickly, short history
for ( for (
let i = speakingHistory.length - 3; let i = speakingHistory.length - 3;
i < speakingHistory.length; i < speakingHistory.length;
i++ i++
) { ) {
history += speakingHistory[i]; history += speakingHistory[i];
}
if (history >= 2) {
// Not silent
isSilent = false;
hasAudioStarted = true;
}
} else if (currentVolume < threshold && !isSilent) {
for (const element of speakingHistory) {
history += element;
}
if (history == 0 // Silent
&& !(
video && (
video.paused
|| video.seeking
|| video.ended
|| video.muted
|| video.volume === 0
)
)
) {
isSilent = true;
skipSilence();
}
} }
speakingHistory.shift(); if (history >= 2) {
speakingHistory.push(Number(currentVolume > threshold)); // Not silent
isSilent = false;
hasAudioStarted = true;
}
} else if (currentVolume < threshold && !isSilent) {
for (const element of speakingHistory) {
history += element;
}
looper(); if (history == 0 // Silent
}, interval);
};
looper(); && !(
video && (
const skipSilence = () => { video.paused
if (config.onlySkipBeginning && hasAudioStarted) { || video.seeking
return; || video.ended
|| video.muted
|| video.volume === 0
)
)
) {
isSilent = true;
skipSilence();
}
} }
if (isSilent && video && !video.paused) { speakingHistory.shift();
video.currentTime += 0.2; // In s speakingHistory.push(Number(currentVolume > threshold));
}
};
playOrSeekHandler = () => { looper();
hasAudioStarted = false; }, interval);
skipSilence();
};
video?.addEventListener('play', playOrSeekHandler);
video?.addEventListener('seeked', playOrSeekHandler);
}; };
return { looper();
async onLoad() {
config = await getConfig();
document.addEventListener( const skipSilence = () => {
'audioCanPlay', if (config.onlySkipBeginning && hasAudioStarted) {
audioCanPlayListener, return;
{ }
passive: true,
},
);
},
onUnload() {
document.removeEventListener(
'audioCanPlay',
audioCanPlayListener,
);
if (playOrSeekHandler) { if (isSilent && video && !video.paused) {
const video = document.querySelector('video'); video.currentTime += 0.2; // In s
video?.removeEventListener('play', playOrSeekHandler);
video?.removeEventListener('seeked', playOrSeekHandler);
}
} }
}; };
});
playOrSeekHandler = () => {
hasAudioStarted = false;
skipSilence();
};
video?.addEventListener('play', playOrSeekHandler);
video?.addEventListener('seeked', playOrSeekHandler);
};
export const onRendererLoad = async ({ getConfig }: RendererContext<SkipSilencesPluginConfig>) => {
config = await getConfig();
document.addEventListener(
'audioCanPlay',
audioCanPlayListener,
{
passive: true,
},
);
};
export const onRendererUnload = () => {
document.removeEventListener(
'audioCanPlay',
audioCanPlayListener,
);
if (playOrSeekHandler) {
const video = document.querySelector('video');
video?.removeEventListener('play', playOrSeekHandler);
video?.removeEventListener('seeked', playOrSeekHandler);
}
};

View File

@ -1,4 +1,11 @@
import { createPluginBuilder } from '../utils/builder'; import is from 'electron-is';
import { createPlugin } from '@/utils';
import { sortSegments } from './segments';
import type { GetPlayerResponse } from '@/types/get-player-response';
import type { Segment, SkipSegment } from './types';
export type SponsorBlockPluginConfig = { export type SponsorBlockPluginConfig = {
enabled: boolean; enabled: boolean;
@ -6,7 +13,9 @@ export type SponsorBlockPluginConfig = {
categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[]; categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[];
}; };
const builder = createPluginBuilder('sponsorblock', { let currentSegments: Segment[] = [];
export default createPlugin({
name: 'SponsorBlock', name: 'SponsorBlock',
restartNeeded: true, restartNeeded: true,
config: { config: {
@ -21,12 +30,83 @@ const builder = createPluginBuilder('sponsorblock', {
'music_offtopic', 'music_offtopic',
], ],
} as SponsorBlockPluginConfig, } as SponsorBlockPluginConfig,
}); async backend({ getConfig, ipc }) {
const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
categories,
)}`;
try {
const resp = await fetch(sponsorBlockURL, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
});
if (resp.status !== 200) {
return [];
}
export default builder; const segments = await resp.json() as SkipSegment[];
return sortSegments(
segments.map((submission) => submission.segment),
);
} catch (error) {
if (is.dev()) {
console.log('error on sponsorblock request:', error);
}
declare global { return [];
interface PluginBuilderList { }
[builder.id]: typeof builder; };
const config = await getConfig();
const { apiURL, categories } = config;
ipc.on('video-src-changed', async (data: GetPlayerResponse) => {
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
ipc.send('sponsorblock-skip', segments);
});
},
renderer: {
timeUpdateListener: (e: Event) => {
if (e.target instanceof HTMLVideoElement) {
const target = e.target;
for (const segment of currentSegments) {
if (
target.currentTime >= segment[0]
&& target.currentTime < segment[1]
) {
target.currentTime = segment[1];
if (window.electronIs.dev()) {
console.log('SponsorBlock: skipping segment', segment);
}
}
}
}
},
resetSegments: () => currentSegments = [],
start({ ipc }) {
ipc.on('sponsorblock-skip', (segments: Segment[]) => {
currentSegments = segments;
});
},
onPlayerApiReady() {
const video = document.querySelector<HTMLVideoElement>('video');
if (!video) return;
video.addEventListener('timeupdate', this.timeUpdateListener);
// Reset segments on song end
video.addEventListener('emptied', this.resetSegments);
},
stop() {
const video = document.querySelector<HTMLVideoElement>('video');
if (!video) return;
video.removeEventListener('timeupdate', this.timeUpdateListener);
video.removeEventListener('emptied', this.resetSegments);
}
} }
} });

View File

@ -1,51 +0,0 @@
import is from 'electron-is';
import { sortSegments } from './segments';
import { SkipSegment } from './types';
import builder from './index';
import type { GetPlayerResponse } from '../../types/get-player-response';
const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
categories,
)}`;
try {
const resp = await fetch(sponsorBlockURL, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
redirect: 'follow',
});
if (resp.status !== 200) {
return [];
}
const segments = await resp.json() as SkipSegment[];
return sortSegments(
segments.map((submission) => submission.segment),
);
} catch (error) {
if (is.dev()) {
console.log('error on sponsorblock request:', error);
}
return [];
}
};
export default builder.createMain(({ getConfig, on, send }) => ({
async onLoad() {
const config = await getConfig();
const { apiURL, categories } = config;
on('video-src-changed', async (_, data: GetPlayerResponse) => {
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
send('sponsorblock-skip', segments);
});
}
}));

View File

@ -1,49 +0,0 @@
import { Segment } from './types';
import builder from './index';
export default builder.createRenderer(({ on }) => {
let currentSegments: Segment[] = [];
const timeUpdateListener = (e: Event) => {
if (e.target instanceof HTMLVideoElement) {
const target = e.target;
for (const segment of currentSegments) {
if (
target.currentTime >= segment[0]
&& target.currentTime < segment[1]
) {
target.currentTime = segment[1];
if (window.electronIs.dev()) {
console.log('SponsorBlock: skipping segment', segment);
}
}
}
}
};
const resetSegments = () => currentSegments = [];
return ({
onLoad() {
on('sponsorblock-skip', (_, segments: Segment[]) => {
currentSegments = segments;
});
},
onPlayerApiReady() {
const video = document.querySelector<HTMLVideoElement>('video');
if (!video) return;
video.addEventListener('timeupdate', timeUpdateListener);
// Reset segments on song end
video.addEventListener('emptied', resetSegments);
},
onUnload() {
const video = document.querySelector<HTMLVideoElement>('video');
if (!video) return;
video.removeEventListener('timeupdate', timeUpdateListener);
video.removeEventListener('emptied', resetSegments);
}
});
});

View File

@ -1,17 +1,84 @@
import { createPluginBuilder } from '../utils/builder'; import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
import nextIcon from '@assets/media-icons-black/next.png?asset&asarUnpack';
import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpack';
const builder = createPluginBuilder('taskbar-mediacontrol', { import { nativeImage } from 'electron';
import { createPlugin } from '@/utils';
import getSongControls from '@/providers/song-controls';
import registerCallback, { type SongInfo } from '@/providers/song-info';
import { mediaIcons } from '@/types/media-icons';
export default createPlugin({
name: 'Taskbar Media Control', name: 'Taskbar Media Control',
restartNeeded: true, restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },
});
export default builder; backend({ window }) {
let currentSongInfo: SongInfo;
declare global { const { playPause, next, previous } = getSongControls(window);
interface PluginBuilderList {
[builder.id]: typeof builder; const setThumbar = (songInfo: SongInfo) => {
// Wait for song to start before setting thumbar
if (!songInfo?.title) {
return;
}
// Win32 require full rewrite of components
window.setThumbarButtons([
{
tooltip: 'Previous',
icon: nativeImage.createFromPath(get('previous')),
click() {
previous();
},
}, {
tooltip: 'Play/Pause',
// Update icon based on play state
icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
click() {
playPause();
},
}, {
tooltip: 'Next',
icon: nativeImage.createFromPath(get('next')),
click() {
next();
},
},
]);
};
// Util
const get = (kind: keyof typeof mediaIcons): string => {
switch (kind) {
case 'play':
return playIcon;
case 'pause':
return pauseIcon;
case 'next':
return nextIcon;
case 'previous':
return previousIcon;
default:
return '';
}
};
registerCallback((songInfo) => {
// Update currentsonginfo for win.on('show')
currentSongInfo = songInfo;
// Update thumbar
setThumbar(songInfo);
});
// Need to set thumbar again after win.show
window.on('show', () => {
setThumbar(currentSongInfo);
});
} }
} });

View File

@ -1,81 +0,0 @@
import { BrowserWindow, nativeImage } from 'electron';
import builder from './index';
import getSongControls from '../../providers/song-controls';
import registerCallback, { SongInfo } from '../../providers/song-info';
import { mediaIcons } from '../../types/media-icons';
import playIcon from '../../../assets/media-icons-black/play.png?asset&asarUnpack';
import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnpack';
import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack';
import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack';
export default builder.createMain(() => {
return {
onLoad(win) {
let currentSongInfo: SongInfo;
const { playPause, next, previous } = getSongControls(win);
const setThumbar = (win: BrowserWindow, songInfo: SongInfo) => {
// Wait for song to start before setting thumbar
if (!songInfo?.title) {
return;
}
// Win32 require full rewrite of components
win.setThumbarButtons([
{
tooltip: 'Previous',
icon: nativeImage.createFromPath(get('previous')),
click() {
previous();
},
}, {
tooltip: 'Play/Pause',
// Update icon based on play state
icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
click() {
playPause();
},
}, {
tooltip: 'Next',
icon: nativeImage.createFromPath(get('next')),
click() {
next();
},
},
]);
};
// Util
const get = (kind: keyof typeof mediaIcons): string => {
switch (kind) {
case 'play':
return playIcon;
case 'pause':
return pauseIcon;
case 'next':
return nextIcon;
case 'previous':
return previousIcon;
default:
return '';
}
};
registerCallback((songInfo) => {
// Update currentsonginfo for win.on('show')
currentSongInfo = songInfo;
// Update thumbar
setThumbar(win, songInfo);
});
// Need to set thumbar again after win.show
win.on('show', () => {
setThumbar(win, currentSongInfo);
});
}
};
});

View File

@ -1,17 +1,98 @@
import { createPluginBuilder } from '../utils/builder'; import { type NativeImage, TouchBar } from 'electron';
const builder = createPluginBuilder('touchbar', { import { createPlugin } from '@/utils';
import getSongControls from '@/providers/song-controls';
import registerCallback from '@/providers/song-info';
export default createPlugin({
name: 'TouchBar', name: 'TouchBar',
restartNeeded: true, restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },
}); backend({ window }) {
const {
TouchBarButton,
TouchBarLabel,
TouchBarSpacer,
TouchBarSegmentedControl,
TouchBarScrubber,
} = TouchBar;
export default builder; // Songtitle label
const songTitle = new TouchBarLabel({
label: '',
});
// This will store the song controls once available
let controls: (() => void)[] = [];
declare global { // This will store the song image once available
interface PluginBuilderList { const songImage: {
[builder.id]: typeof builder; icon?: NativeImage;
} = {};
// Pause/play button
const pausePlayButton = new TouchBarButton({});
// The song control buttons (control functions are in the same order)
const buttons = new TouchBarSegmentedControl({
mode: 'buttons',
segments: [
new TouchBarButton({
label: '⏮',
}),
pausePlayButton,
new TouchBarButton({
label: '⏭',
}),
new TouchBarButton({
label: '👎',
}),
new TouchBarButton({
label: '👍',
}),
],
change: (i) => controls[i](),
});
// This is the touchbar object, this combines everything with proper layout
const touchBar = new TouchBar({
items: [
new TouchBarScrubber({
items: [songImage, songTitle],
continuous: false,
}),
new TouchBarSpacer({
size: 'flexible',
}),
buttons,
],
});
const { playPause, next, previous, dislike, like } = getSongControls(window);
// If the page is ready, register the callback
window.once('ready-to-show', () => {
controls = [previous, playPause, next, dislike, like];
// Register the callback
registerCallback((songInfo) => {
// Song information changed, so lets update the touchBar
// Set the song title
songTitle.label = songInfo.title;
// Changes the pause button if paused
pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸';
// Get image source
songImage.icon = songInfo.image
? songInfo.image.resize({ height: 23 })
: undefined;
window.setTouchBar(touchBar);
});
});
} }
} });

View File

@ -1,96 +0,0 @@
import { TouchBar, NativeImage } from 'electron';
import builder from './index';
import registerCallback from '../../providers/song-info';
import getSongControls from '../../providers/song-controls';
export default builder.createMain(() => {
return {
onLoad(win) {
const {
TouchBarButton,
TouchBarLabel,
TouchBarSpacer,
TouchBarSegmentedControl,
TouchBarScrubber,
} = TouchBar;
// Songtitle label
const songTitle = new TouchBarLabel({
label: '',
});
// This will store the song controls once available
let controls: (() => void)[] = [];
// This will store the song image once available
const songImage: {
icon?: NativeImage;
} = {};
// Pause/play button
const pausePlayButton = new TouchBarButton({});
// The song control buttons (control functions are in the same order)
const buttons = new TouchBarSegmentedControl({
mode: 'buttons',
segments: [
new TouchBarButton({
label: '⏮',
}),
pausePlayButton,
new TouchBarButton({
label: '⏭',
}),
new TouchBarButton({
label: '👎',
}),
new TouchBarButton({
label: '👍',
}),
],
change: (i) => controls[i](),
});
// This is the touchbar object, this combines everything with proper layout
const touchBar = new TouchBar({
items: [
new TouchBarScrubber({
items: [songImage, songTitle],
continuous: false,
}),
new TouchBarSpacer({
size: 'flexible',
}),
buttons,
],
});
const { playPause, next, previous, dislike, like } = getSongControls(win);
// If the page is ready, register the callback
win.once('ready-to-show', () => {
controls = [previous, playPause, next, dislike, like];
// Register the callback
registerCallback((songInfo) => {
// Song information changed, so lets update the touchBar
// Set the song title
songTitle.label = songInfo.title;
// Changes the pause button if paused
pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸';
// Get image source
songImage.icon = songInfo.image
? songInfo.image.resize({ height: 23 })
: undefined;
win.setTouchBar(touchBar);
});
});
}
};
});

View File

@ -1,17 +1,89 @@
import { createPluginBuilder } from '../utils/builder'; import { net } from 'electron';
const builder = createPluginBuilder('tuna-obs', { import is from 'electron-is';
import { createPlugin } from '@/utils';
import registerCallback from '@/providers/song-info';
interface Data {
album: string | null | undefined;
album_url: string;
artists: string[];
cover: string;
cover_url: string;
duration: number;
progress: number;
status: string;
title: string;
}
export default createPlugin({
name: 'Tuna OBS', name: 'Tuna OBS',
restartNeeded: true, restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },
}); backend: {
data: {
cover: '',
cover_url: '',
title: '',
artists: [] as string[],
status: '',
progress: 0,
duration: 0,
album_url: '',
album: undefined,
} as Data,
start({ ipc }) {
const secToMilisec = (t: number) => Math.round(Number(t) * 1e3);
export default builder; const post = (data: Data) => {
const port = 1608;
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': '*',
};
const url = `http://127.0.0.1:${port}/`;
net.fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ data }),
}).catch((error: { code: number, errno: number }) => {
if (is.dev()) {
console.debug(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`);
}
});
};
declare global { ipc.on('ytmd:player-api-loaded', () => ipc.send('setupTimeChangedListener'));
interface PluginBuilderList { ipc.on('timeChanged', (t: number) => {
[builder.id]: typeof builder; if (!this.data.title) {
return;
}
this.data.progress = secToMilisec(t);
post(this.data);
});
registerCallback((songInfo) => {
if (!songInfo.title && !songInfo.artist) {
return;
}
this.data.duration = secToMilisec(songInfo.songDuration);
this.data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0);
this.data.cover = songInfo.imageSrc ?? '';
this.data.cover_url = songInfo.imageSrc ?? '';
this.data.album_url = songInfo.imageSrc ?? '';
this.data.title = songInfo.title;
this.data.artists = [songInfo.artist];
this.data.status = songInfo.isPaused ? 'stopped' : 'playing';
this.data.album = songInfo.album;
post(this.data);
});
}
} }
} });

View File

@ -1,85 +0,0 @@
import { net } from 'electron';
import is from 'electron-is';
import builder from './index';
import registerCallback from '../../providers/song-info';
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: [] as string[],
status: '',
progress: 0,
duration: 0,
album_url: '',
album: undefined,
};
const post = (data: Data) => {
const port = 1608;
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': '*',
};
const url = `http://127.0.0.1:${port}/`;
net.fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ data }),
}).catch((error: { code: number, errno: number }) => {
if (is.dev()) {
console.debug(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`);
}
});
};
export default builder.createMain(({ send, handle, on }) => {
return {
onLoad() {
on('ytmd:player-api-loaded', () => send('setupTimeChangedListener'));
on('timeChanged', (t: number) => {
if (!data.title) {
return;
}
data.progress = secToMilisec(t);
post(data);
});
registerCallback((songInfo) => {
if (!songInfo.title && !songInfo.artist) {
return;
}
data.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0);
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';
data.album = songInfo.album;
post(data);
});
}
};
});

View File

@ -1,7 +1,12 @@
import buttonTemplate from './templates/button_template.html?raw';
import forceHideStyle from './force-hide.css?inline'; import forceHideStyle from './force-hide.css?inline';
import buttonSwitcherStyle from './button-switcher.css?inline'; import buttonSwitcherStyle from './button-switcher.css?inline';
import { createPluginBuilder } from '../utils/builder'; import { createPlugin } from '@/utils';
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '@/plugins/precise-volume/renderer';
import { YoutubePlayer } from '@/types/youtube-player';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { ThumbnailElement } from '@/types/get-player-response';
export type VideoTogglePluginConfig = { export type VideoTogglePluginConfig = {
enabled: boolean; enabled: boolean;
@ -11,7 +16,7 @@ export type VideoTogglePluginConfig = {
align: 'left' | 'middle' | 'right'; align: 'left' | 'middle' | 'right';
} }
const builder = createPluginBuilder('video-toggle', { export default createPlugin({
name: 'Video Toggle', name: 'Video Toggle',
restartNeeded: true, restartNeeded: true,
config: { config: {
@ -21,16 +26,273 @@ const builder = createPluginBuilder('video-toggle', {
forceHide: false, forceHide: false,
align: 'left', align: 'left',
} as VideoTogglePluginConfig, } as VideoTogglePluginConfig,
styles: [ stylesheets: [
buttonSwitcherStyle, buttonSwitcherStyle,
forceHideStyle, forceHideStyle,
], ],
menu: async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{
label: 'Mode',
submenu: [
{
label: 'Custom toggle',
type: 'radio',
checked: config.mode === 'custom',
click() {
setConfig({ mode: 'custom' });
},
},
{
label: 'Native toggle',
type: 'radio',
checked: config.mode === 'native',
click() {
setConfig({ mode: 'native' });
},
},
{
label: 'Disabled',
type: 'radio',
checked: config.mode === 'disabled',
click() {
setConfig({ mode: 'disabled' });
},
},
],
},
{
label: 'Alignment',
submenu: [
{
label: 'Left',
type: 'radio',
checked: config.align === 'left',
click() {
setConfig({ align: 'left' });
},
},
{
label: 'Middle',
type: 'radio',
checked: config.align === 'middle',
click() {
setConfig({ align: 'middle' });
},
},
{
label: 'Right',
type: 'radio',
checked: config.align === 'right',
click() {
setConfig({ align: 'right' });
},
},
],
},
{
label: 'Force Remove Video Tab',
type: 'checkbox',
checked: config.forceHide,
click(item) {
setConfig({ forceHide: item.checked });
},
},
];
},
renderer: {
config: null as VideoTogglePluginConfig | null,
applyStyleClass: (config: VideoTogglePluginConfig) => {
if (config.forceHide) {
document.body.classList.add('video-toggle-force-hide');
document.body.classList.remove('video-toggle-custom-mode');
} else if (!config.mode || config.mode === 'custom') {
document.body.classList.add('video-toggle-custom-mode');
document.body.classList.remove('video-toggle-force-hide');
}
},
async start({ getConfig }) {
const config = await getConfig();
this.applyStyleClass(config);
if (config.forceHide) {
return;
}
switch (config.mode) {
case 'native': {
document.querySelector('ytmusic-player-page')?.setAttribute('has-av-switcher', '');
document.querySelector('ytmusic-player')?.setAttribute('has-av-switcher', '');
return;
}
case 'disabled': {
document.querySelector('ytmusic-player-page')?.removeAttribute('has-av-switcher');
document.querySelector('ytmusic-player')?.removeAttribute('has-av-switcher');
return;
}
}
},
async onPlayerApiReady(api, { getConfig }) {
const config = await getConfig();
this.config = config;
const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ?
preciseVolumeMoveVolumeHud as (_: boolean) => void
: (() => {});
const player = document.querySelector<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player');
const video = document.querySelector<HTMLVideoElement>('video');
const switchButtonDiv = ElementFromHtml(buttonTemplate);
const forceThumbnail = (img: HTMLImageElement) => {
const thumbnails: ThumbnailElement[] = (document.querySelector('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? [];
if (thumbnails && thumbnails.length > 0) {
const thumbnail = thumbnails.at(-1)?.url.split('?')[0];
if (thumbnail) img.src = thumbnail;
}
};
const setVideoState = (showVideo: boolean) => {
if (this.config) {
this.config.hideVideo = !showVideo;
}
window.mainConfig.plugins.setOptions('video-toggle', config);
const checkbox = document.querySelector<HTMLInputElement>('.video-switch-button-checkbox'); // custom mode
if (checkbox) checkbox.checked = !config.hideVideo;
if (player) {
player.style.margin = showVideo ? '' : 'auto 0px';
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
document.querySelector<HTMLElement>('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none';
document.querySelector<HTMLElement>('#song-image')!.style.display = showVideo ? 'none' : 'block';
if (showVideo && video && !video.style.top) {
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
}
moveVolumeHud(showVideo);
}
};
const videoStarted = () => {
if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') {
// Video doesn't exist -> switch to song mode
setVideoState(false);
// Hide toggle button
switchButtonDiv.style.display = 'none';
} else {
const songImage = document.querySelector<HTMLImageElement>('#song-image img');
if (!songImage) {
return;
}
// Switch to high-res thumbnail
forceThumbnail(songImage);
// Show toggle button
switchButtonDiv.style.display = 'initial';
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
if (!this.config?.hideVideo && document.querySelector<HTMLElement>('#song-video.ytmusic-player')?.style.display === 'none') {
setVideoState(true);
} else {
moveVolumeHud(!this.config?.hideVideo);
}
}
};
/**
* On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container
* this function fix the problem by overriding that override :)
*/
const forcePlaybackMode = () => {
if (player) {
const playbackModeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.target instanceof HTMLElement) {
const target = mutation.target;
if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') {
playbackModeObserver.disconnect();
target.setAttribute('playback-mode', 'ATV_PREFERRED');
}
}
}
});
playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] });
}
};
const observeThumbnail = () => {
const playbackModeObserver = new MutationObserver((mutations) => {
if (!player?.videoMode_) {
return;
}
for (const mutation of mutations) {
if (mutation.target instanceof HTMLImageElement) {
const target = mutation.target;
if (!target.src.startsWith('data:')) {
continue;
}
forceThumbnail(target);
}
}
});
playbackModeObserver.observe(document.querySelector('#song-image img')!, { attributeFilter: ['src'] });
};
if (config.mode !== 'native' && config.mode != 'disabled') {
document.querySelector<HTMLVideoElement>('#player')?.prepend(switchButtonDiv);
setVideoState(!config.hideVideo);
forcePlaybackMode();
// Fix black video
if (video) {
video.style.height = 'auto';
}
//Prevents bubbling to the player which causes it to stop or resume
switchButtonDiv.addEventListener('click', (e) => {
e.stopPropagation();
});
// Button checked = show video
switchButtonDiv.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
setVideoState(target.checked);
});
video?.addEventListener('srcChanged', videoStarted);
observeThumbnail();
switch (config.align) {
case 'right': {
switchButtonDiv.style.left = 'calc(100% - 240px)';
return;
}
case 'middle': {
switchButtonDiv.style.left = 'calc(50% - 120px)';
return;
}
default:
case 'left': {
switchButtonDiv.style.left = '0px';
}
}
}
},
onConfigChange(newConfig) {
this.config = newConfig;
this.applyStyleClass(newConfig);
},
},
}); });
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,74 +0,0 @@
import builder from './index';
export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{
label: 'Mode',
submenu: [
{
label: 'Custom toggle',
type: 'radio',
checked: config.mode === 'custom',
click() {
setConfig({ mode: 'custom' });
},
},
{
label: 'Native toggle',
type: 'radio',
checked: config.mode === 'native',
click() {
setConfig({ mode: 'native' });
},
},
{
label: 'Disabled',
type: 'radio',
checked: config.mode === 'disabled',
click() {
setConfig({ mode: 'disabled' });
},
},
],
},
{
label: 'Alignment',
submenu: [
{
label: 'Left',
type: 'radio',
checked: config.align === 'left',
click() {
setConfig({ align: 'left' });
},
},
{
label: 'Middle',
type: 'radio',
checked: config.align === 'middle',
click() {
setConfig({ align: 'middle' });
},
},
{
label: 'Right',
type: 'radio',
checked: config.align === 'right',
click() {
setConfig({ align: 'right' });
},
},
],
},
{
label: 'Force Remove Video Tab',
type: 'checkbox',
checked: config.forceHide,
click(item) {
setConfig({ forceHide: item.checked });
},
},
];
});

View File

@ -1,207 +0,0 @@
import buttonTemplate from './templates/button_template.html?raw';
import builder, { type VideoTogglePluginConfig } from './index';
import { ElementFromHtml } from '../utils/renderer';
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/renderer';
import type { ThumbnailElement } from '../../types/get-player-response';
import type { YoutubePlayer } from '../../types/youtube-player';
export default builder.createRenderer(({ getConfig }) => {
const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ?
preciseVolumeMoveVolumeHud as (_: boolean) => void
: (() => {});
let config: VideoTogglePluginConfig = builder.config;
let player: HTMLElement & { videoMode_: boolean } | null;
let video: HTMLVideoElement | null;
let api: YoutubePlayer;
const switchButtonDiv = ElementFromHtml(buttonTemplate);
function setup(playerApi: YoutubePlayer) {
api = playerApi;
player = document.querySelector<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player');
video = document.querySelector<HTMLVideoElement>('video');
document.querySelector<HTMLVideoElement>('#player')?.prepend(switchButtonDiv);
setVideoState(!config.hideVideo);
forcePlaybackMode();
// Fix black video
if (video) {
video.style.height = 'auto';
}
//Prevents bubbling to the player which causes it to stop or resume
switchButtonDiv.addEventListener('click', (e) => {
e.stopPropagation();
});
// Button checked = show video
switchButtonDiv.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
setVideoState(target.checked);
});
video?.addEventListener('srcChanged', videoStarted);
observeThumbnail();
switch (config.align) {
case 'right': {
switchButtonDiv.style.left = 'calc(100% - 240px)';
return;
}
case 'middle': {
switchButtonDiv.style.left = 'calc(50% - 120px)';
return;
}
default:
case 'left': {
switchButtonDiv.style.left = '0px';
}
}
}
function setVideoState(showVideo: boolean) {
config.hideVideo = !showVideo;
window.mainConfig.plugins.setOptions('video-toggle', config);
const checkbox = document.querySelector<HTMLInputElement>('.video-switch-button-checkbox'); // custom mode
if (checkbox) checkbox.checked = !config.hideVideo;
if (player) {
player.style.margin = showVideo ? '' : 'auto 0px';
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
document.querySelector<HTMLElement>('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none';
document.querySelector<HTMLElement>('#song-image')!.style.display = showVideo ? 'none' : 'block';
if (showVideo && video && !video.style.top) {
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
}
moveVolumeHud(showVideo);
}
}
function videoStarted() {
if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') {
// Video doesn't exist -> switch to song mode
setVideoState(false);
// Hide toggle button
switchButtonDiv.style.display = 'none';
} else {
const songImage = document.querySelector<HTMLImageElement>('#song-image img');
if (!songImage) {
return;
}
// Switch to high-res thumbnail
forceThumbnail(songImage);
// Show toggle button
switchButtonDiv.style.display = 'initial';
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
if (!config.hideVideo && document.querySelector<HTMLElement>('#song-video.ytmusic-player')?.style.display === 'none') {
setVideoState(true);
} else {
moveVolumeHud(!config.hideVideo);
}
}
}
// On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container
// this function fix the problem by overriding that override :)
function forcePlaybackMode() {
if (player) {
const playbackModeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.target instanceof HTMLElement) {
const target = mutation.target;
if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') {
playbackModeObserver.disconnect();
target.setAttribute('playback-mode', 'ATV_PREFERRED');
}
}
}
});
playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] });
}
}
function observeThumbnail() {
const playbackModeObserver = new MutationObserver((mutations) => {
if (!player?.videoMode_) {
return;
}
for (const mutation of mutations) {
if (mutation.target instanceof HTMLImageElement) {
const target = mutation.target;
if (!target.src.startsWith('data:')) {
continue;
}
forceThumbnail(target);
}
}
});
playbackModeObserver.observe(document.querySelector('#song-image img')!, { attributeFilter: ['src'] });
}
function forceThumbnail(img: HTMLImageElement) {
const thumbnails: ThumbnailElement[] = (document.querySelector('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? [];
if (thumbnails && thumbnails.length > 0) {
const thumbnail = thumbnails.at(-1)?.url.split('?')[0];
if (typeof thumbnail === 'string') img.src = thumbnail;
}
}
const applyStyleClass = (config: VideoTogglePluginConfig) => {
if (config.forceHide) {
document.body.classList.add('video-toggle-force-hide');
document.body.classList.remove('video-toggle-custom-mode');
} else if (!config.mode || config.mode === 'custom') {
document.body.classList.add('video-toggle-custom-mode');
document.body.classList.remove('video-toggle-force-hide');
}
};
return {
async onLoad() {
config = await getConfig();
applyStyleClass(config);
if (config.forceHide) {
return;
}
switch (config.mode) {
case 'native': {
document.querySelector('ytmusic-player-page')?.setAttribute('has-av-switcher', '');
document.querySelector('ytmusic-player')?.setAttribute('has-av-switcher', '');
return;
}
case 'disabled': {
document.querySelector('ytmusic-player-page')?.removeAttribute('has-av-switcher');
document.querySelector('ytmusic-player')?.removeAttribute('has-av-switcher');
return;
}
}
},
onPlayerApiReady(playerApi) {
if (config.mode !== 'native' && config.mode != 'disabled') setup(playerApi);
},
onConfigChange(newConfig) {
config = newConfig;
applyStyleClass(newConfig);
}
};
});

View File

@ -1,6 +1,11 @@
import emptyStyle from './empty-player.css?inline'; import emptyStyle from './empty-player.css?inline';
import { createPlugin } from '@/utils';
import { createPluginBuilder } from '../utils/builder'; import { Visualizer } from './visualizers/visualizer';
import {
ButterchurnVisualizer as butterchurn,
VudioVisualizer as vudio,
WaveVisualizer as wave
} from './visualizers';
type WaveColor = { type WaveColor = {
gradient: string[]; gradient: string[];
@ -51,7 +56,7 @@ export type VisualizerPluginConfig = {
}; };
}; };
const builder = createPluginBuilder('visualizer', { export default createPlugin({
name: 'Visualizer', name: 'Visualizer',
restartNeeded: true, restartNeeded: true,
config: { config: {
@ -120,13 +125,97 @@ const builder = createPluginBuilder('visualizer', {
], ],
}, },
} as VisualizerPluginConfig, } as VisualizerPluginConfig,
styles: [emptyStyle], stylesheets: [emptyStyle],
menu: async ({ getConfig, setConfig }) => {
const config = await getConfig();
const visualizerTypes = ['butterchurn', 'vudio', 'wave'] as const; // For bundling
return [
{
label: 'Type',
submenu: visualizerTypes.map((visualizerType) => ({
label: visualizerType,
type: 'radio',
checked: config.type === visualizerType,
click() {
setConfig({ type: visualizerType });
},
})),
},
];
},
async renderer({ getConfig }) {
const config = await getConfig();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let visualizerType: { new(...args: any[]): Visualizer<unknown> } = vudio;
if (config.type === 'wave') {
visualizerType = wave;
} else if (config.type === 'butterchurn') {
visualizerType = butterchurn;
}
document.addEventListener(
'audioCanPlay',
(e) => {
const video = document.querySelector<HTMLVideoElement & { captureStream(): MediaStream; }>('video');
if (!video) {
return;
}
const visualizerContainer = document.querySelector<HTMLElement>('#player');
if (!visualizerContainer) {
return;
}
let canvas = document.querySelector<HTMLCanvasElement>('#visualizer');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'visualizer';
visualizerContainer?.prepend(canvas);
}
const resizeCanvas = () => {
if (canvas) {
canvas.width = visualizerContainer.clientWidth;
canvas.height = visualizerContainer.clientHeight;
}
};
resizeCanvas();
const gainNode = e.detail.audioContext.createGain();
gainNode.gain.value = 1.25;
e.detail.audioSource.connect(gainNode);
const visualizer = new visualizerType(
e.detail.audioContext,
e.detail.audioSource,
visualizerContainer,
canvas,
gainNode,
video.captureStream(),
config,
);
const resizeVisualizer = (width: number, height: number) => {
resizeCanvas();
visualizer.resize(width, height);
};
resizeVisualizer(canvas.width, canvas.height);
const visualizerContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
resizeVisualizer(entry.contentRect.width, entry.contentRect.height);
}
});
visualizerContainerObserver.observe(visualizerContainer);
visualizer.render();
},
{ passive: true },
);
},
}); });
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,21 +0,0 @@
import builder from './index';
const visualizerTypes = ['butterchurn', 'vudio', 'wave'] as const; // For bundling
export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{
label: 'Type',
submenu: visualizerTypes.map((visualizerType) => ({
label: visualizerType,
type: 'radio',
checked: config.type === visualizerType,
click() {
setConfig({ type: visualizerType });
},
})),
},
];
});

View File

@ -1,82 +0,0 @@
import { ButterchurnVisualizer as butterchurn, WaveVisualizer as wave, VudioVisualizer as vudio } from './visualizers';
import { Visualizer } from './visualizers/visualizer';
import builder from './index';
export default builder.createRenderer(({ getConfig }) => {
return {
async onLoad() {
const config = await getConfig();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let visualizerType: { new(...args: any[]): Visualizer<unknown> } = vudio;
if (config.type === 'wave') {
visualizerType = wave;
} else if (config.type === 'butterchurn') {
visualizerType = butterchurn;
}
document.addEventListener(
'audioCanPlay',
(e) => {
const video = document.querySelector<HTMLVideoElement & { captureStream(): MediaStream; }>('video');
if (!video) {
return;
}
const visualizerContainer = document.querySelector<HTMLElement>('#player');
if (!visualizerContainer) {
return;
}
let canvas = document.querySelector<HTMLCanvasElement>('#visualizer');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'visualizer';
visualizerContainer?.prepend(canvas);
}
const resizeCanvas = () => {
if (canvas) {
canvas.width = visualizerContainer.clientWidth;
canvas.height = visualizerContainer.clientHeight;
}
};
resizeCanvas();
const gainNode = e.detail.audioContext.createGain();
gainNode.gain.value = 1.25;
e.detail.audioSource.connect(gainNode);
const visualizer = new visualizerType(
e.detail.audioContext,
e.detail.audioSource,
visualizerContainer,
canvas,
gainNode,
video.captureStream(),
config,
);
const resizeVisualizer = (width: number, height: number) => {
resizeCanvas();
visualizer.resize(width, height);
};
resizeVisualizer(canvas.width, canvas.height);
const visualizerContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
resizeVisualizer(entry.contentRect.width, entry.contentRect.height);
}
});
visualizerContainerObserver.observe(visualizerContainer);
visualizer.render();
},
{ passive: true },
);
},
};
});

View File

@ -1,4 +1,4 @@
import youtubeMusicTrayIcon from '../../assets/youtube-music-tray.png?asset&asarUnpack'; import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack';
const promptOptions = { const promptOptions = {
customStylesheet: 'dark', customStylesheet: 'dark',

View File

@ -1,6 +1,7 @@
import { startingPages } from './providers/extracted-data'; import { startingPages } from './providers/extracted-data';
import setupSongInfo from './providers/song-info-front'; import setupSongInfo from './providers/song-info-front';
import { import {
createContext,
forceLoadRendererPlugin, forceLoadRendererPlugin,
forceUnloadRendererPlugin, forceUnloadRendererPlugin,
getAllLoadedRendererPlugins, getAllLoadedRendererPlugins,
@ -74,10 +75,10 @@ function onApiLoaded() {
{ passive: true }, { passive: true },
); );
Object.values(getAllLoadedRendererPlugins()) Object.entries(getAllLoadedRendererPlugins())
.forEach((plugin) => { .forEach(([id, plugin]) => {
if (typeof plugin.renderer !== 'function') { if (typeof plugin.renderer !== 'function') {
plugin.renderer?.onPlayerApiReady?.(api!); plugin.renderer?.onPlayerApiReady?.(api!, createContext(id));
} }
}); });
@ -134,7 +135,7 @@ function onApiLoaded() {
if (api) { if (api) {
const plugin = getLoadedRendererPlugin(id); const plugin = getLoadedRendererPlugin(id);
if (plugin && typeof plugin.renderer !== 'function') { if (plugin && typeof plugin.renderer !== 'function') {
plugin.renderer?.onPlayerApiReady?.(api); plugin.renderer?.onPlayerApiReady?.(api, createContext(id));
} }
} }
}, },

View File

@ -1,11 +1,11 @@
import { Menu, nativeImage, Tray } from 'electron'; import { Menu, nativeImage, Tray } from 'electron';
import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack';
import { restart } from './providers/app-controls'; import { restart } from './providers/app-controls';
import config from './config'; import config from './config';
import getSongControls from './providers/song-controls'; import getSongControls from './providers/song-controls';
import youtubeMusicTrayIcon from '../assets/youtube-music-tray.png?asset&asarUnpack';
import type { MenuTemplate } from './menu'; import type { MenuTemplate } from './menu';
// Prevent tray being garbage collected // Prevent tray being garbage collected

View File

@ -18,10 +18,13 @@ export type PluginLifecycleExtra<Config, Context, This> = This & {
start?: PluginLifecycleSimple<Context, This>; start?: PluginLifecycleSimple<Context, This>;
stop?: PluginLifecycleSimple<Context, This>; stop?: PluginLifecycleSimple<Context, This>;
onConfigChange?: (this: This, newConfig: Config) => void | Promise<void>; onConfigChange?: (this: This, newConfig: Config) => void | Promise<void>;
onPlayerApiReady?: (this: This, playerApi: YoutubePlayer) => void | Promise<void>;
}; };
export type RendererPluginLifecycleExtra<Config, Context, This> = This & PluginLifecycleExtra<Config, Context, This> & {
onPlayerApiReady?: (this: This, playerApi: YoutubePlayer, context: Context) => void | Promise<void>;
}
export type PluginLifecycle<Config, Context, This> = PluginLifecycleSimple<Context, This> | PluginLifecycleExtra<Config, Context, This>; export type PluginLifecycle<Config, Context, This> = PluginLifecycleSimple<Context, This> | PluginLifecycleExtra<Config, Context, This>;
export type RendererPluginLifecycle<Config, Context, This> = PluginLifecycleSimple<Context, This> | RendererPluginLifecycleExtra<Config, Context, This>;
export interface PluginDef< export interface PluginDef<
BackendProperties, BackendProperties,
@ -46,5 +49,5 @@ export interface PluginDef<
} & PluginLifecycle<Config, PreloadContext<Config>, PreloadProperties>; } & PluginLifecycle<Config, PreloadContext<Config>, PreloadProperties>;
renderer?: { renderer?: {
[Key in keyof RendererProperties]: RendererProperties[Key] [Key in keyof RendererProperties]: RendererProperties[Key]
} & PluginLifecycle<Config, RendererContext<Config>, RendererProperties>; } & RendererPluginLifecycle<Config, RendererContext<Config>, RendererProperties>;
} }

View File

@ -17,7 +17,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@assets": ["./assets/*"] "@assets/*": ["./assets/*"]
} }
}, },
"exclude": ["./dist"], "exclude": ["./dist"],