From 3ffbfbe0e335903f4c1a3b879664d01ed3b57fe0 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Mon, 27 Nov 2023 18:41:50 +0900 Subject: [PATCH] convert plugins --- src/config/index.ts | 2 +- src/config/store.ts | 2 +- src/loader/renderer.ts | 2 +- src/menu.ts | 6 +- src/plugins/adblocker/index.ts | 6 +- src/plugins/crossfade/index.ts | 2 +- src/plugins/discord/index.ts | 4 +- src/plugins/discord/main.ts | 2 +- src/plugins/discord/menu.ts | 3 +- src/plugins/downloader/index.ts | 22 +- src/plugins/downloader/main/index.ts | 4 +- src/plugins/in-app-menu/index.ts | 6 +- src/plugins/last-fm/index.ts | 40 ++- src/plugins/last-fm/main.ts | 64 ++--- src/plugins/lumiastream/index.ts | 87 ++++++- src/plugins/lumiastream/main.ts | 88 ------- src/plugins/lyrics-genius/index.ts | 39 ++- src/plugins/lyrics-genius/main.ts | 36 ++- src/plugins/lyrics-genius/menu.ts | 18 -- src/plugins/lyrics-genius/renderer.ts | 164 ++++++------ src/plugins/navigation/index.ts | 26 +- src/plugins/navigation/renderer.ts | 20 -- src/plugins/no-google-login/index.ts | 46 +++- src/plugins/no-google-login/main.ts | 9 - src/plugins/no-google-login/renderer.ts | 39 --- src/plugins/notifications/index.ts | 52 ++-- src/plugins/notifications/interactive.ts | 25 +- src/plugins/notifications/main.ts | 30 +-- src/plugins/notifications/menu.ts | 12 +- src/plugins/notifications/utils.ts | 8 +- src/plugins/picture-in-picture/index.ts | 25 +- src/plugins/picture-in-picture/main.ts | 95 ++++--- src/plugins/picture-in-picture/menu.ts | 11 +- src/plugins/picture-in-picture/renderer.ts | 71 +++--- src/plugins/playback-speed/index.ts | 17 +- src/plugins/playback-speed/renderer.ts | 57 ++--- src/plugins/precise-volume/index.ts | 120 ++++++++- src/plugins/precise-volume/main.ts | 17 -- src/plugins/precise-volume/menu.ts | 90 ------- src/plugins/precise-volume/override.ts | 4 +- src/plugins/precise-volume/renderer.ts | 39 ++- src/plugins/quality-changer/index.ts | 66 ++++- src/plugins/quality-changer/main.ts | 17 -- src/plugins/quality-changer/renderer.ts | 54 ---- src/plugins/shortcuts/index.ts | 17 +- src/plugins/shortcuts/main.ts | 97 ++++--- src/plugins/shortcuts/menu.ts | 11 +- src/plugins/shortcuts/mpris.ts | 6 +- src/plugins/skip-silences/index.ts | 17 +- src/plugins/skip-silences/renderer.ts | 227 ++++++++-------- src/plugins/sponsorblock/index.ts | 96 ++++++- src/plugins/sponsorblock/main.ts | 51 ---- src/plugins/sponsorblock/renderer.ts | 49 ---- src/plugins/taskbar-mediacontrol/index.ts | 83 +++++- src/plugins/taskbar-mediacontrol/main.ts | 81 ------ src/plugins/touchbar/index.ts | 97 ++++++- src/plugins/touchbar/main.ts | 96 ------- src/plugins/tuna-obs/index.ts | 88 ++++++- src/plugins/tuna-obs/main.ts | 85 ------ src/plugins/video-toggle/index.ts | 284 ++++++++++++++++++++- src/plugins/video-toggle/menu.ts | 74 ------ src/plugins/video-toggle/renderer.ts | 207 --------------- src/plugins/visualizer/index.ts | 113 +++++++- src/plugins/visualizer/menu.ts | 21 -- src/plugins/visualizer/renderer.ts | 82 ------ src/providers/prompt-options.ts | 2 +- src/renderer.ts | 9 +- src/tray.ts | 4 +- src/types/plugins.ts | 7 +- tsconfig.json | 2 +- 70 files changed, 1617 insertions(+), 1836 deletions(-) delete mode 100644 src/plugins/lumiastream/main.ts delete mode 100644 src/plugins/lyrics-genius/menu.ts delete mode 100644 src/plugins/navigation/renderer.ts delete mode 100644 src/plugins/no-google-login/main.ts delete mode 100644 src/plugins/no-google-login/renderer.ts delete mode 100644 src/plugins/precise-volume/main.ts delete mode 100644 src/plugins/precise-volume/menu.ts delete mode 100644 src/plugins/quality-changer/main.ts delete mode 100644 src/plugins/quality-changer/renderer.ts delete mode 100644 src/plugins/sponsorblock/main.ts delete mode 100644 src/plugins/sponsorblock/renderer.ts delete mode 100644 src/plugins/taskbar-mediacontrol/main.ts delete mode 100644 src/plugins/touchbar/main.ts delete mode 100644 src/plugins/tuna-obs/main.ts delete mode 100644 src/plugins/video-toggle/menu.ts delete mode 100644 src/plugins/video-toggle/renderer.ts delete mode 100644 src/plugins/visualizer/menu.ts delete mode 100644 src/plugins/visualizer/renderer.ts diff --git a/src/config/index.ts b/src/config/index.ts index d9e03365..4b66e976 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -6,7 +6,7 @@ import defaultConfig from './defaults'; import store from './store'; import plugins from './plugins'; -import { restart } from '../providers/app-controls'; +import { restart } from '@/providers/app-controls'; const set = (key: string, value: unknown) => { store.set(key, value); diff --git a/src/config/store.ts b/src/config/store.ts index 22f1061b..fb3ebf38 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -5,7 +5,7 @@ import { allPlugins } from 'virtual:plugins'; import defaults from './defaults'; -import { DefaultPresetList, type Preset } from '../plugins/downloader/types'; +import { DefaultPresetList, type Preset } from '@/plugins/downloader/types'; const setDefaultPluginOptions = ( store: Conf>, diff --git a/src/loader/renderer.ts b/src/loader/renderer.ts index d0029b3e..df339831 100644 --- a/src/loader/renderer.ts +++ b/src/loader/renderer.ts @@ -10,7 +10,7 @@ import { startPlugin, stopPlugin } from '@/utils'; const unregisterStyleMap: Record void)[]> = {}; const loadedPluginMap: Record> = {}; -const createContext = (id: string): RendererContext => ({ +export const createContext = (id: string): RendererContext => ({ getConfig: () => window.mainConfig.plugins.getOptions(id), setConfig: async (newConfig) => { await window.ipcRenderer.invoke('set-config', id, newConfig); diff --git a/src/menu.ts b/src/menu.ts index 4173052e..35bcb21a 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -9,15 +9,13 @@ import { } from 'electron'; import prompt from 'custom-electron-prompt'; +import { allPlugins } from 'virtual:plugins'; + import { restart } from './providers/app-controls'; import config from './config'; import { startingPages } from './providers/extracted-data'; 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'; export type MenuTemplate = Electron.MenuItemConstructorOptions[]; diff --git a/src/plugins/adblocker/index.ts b/src/plugins/adblocker/index.ts index d144ed66..c0e36f17 100644 --- a/src/plugins/adblocker/index.ts +++ b/src/plugins/adblocker/index.ts @@ -1,9 +1,9 @@ import { blockers } from './types'; 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 { inject, isInjected } from '@/plugins/adblocker/injectors/inject'; +import injectCliqzPreload from './injectors/inject-cliqz-preload'; +import { inject, isInjected } from './injectors/inject'; import type { BrowserWindow } from 'electron'; diff --git a/src/plugins/crossfade/index.ts b/src/plugins/crossfade/index.ts index ba728e75..7ce33c7c 100644 --- a/src/plugins/crossfade/index.ts +++ b/src/plugins/crossfade/index.ts @@ -8,7 +8,7 @@ import { Howl } from 'howler'; import promptOptions from '@/providers/prompt-options'; import { getNetFetchAsFetch } from '@/plugins/utils/main'; import { createPlugin } from '@/utils'; -import { VolumeFader } from '@/plugins/crossfade/fader'; +import { VolumeFader } from './fader'; import type { RendererContext } from '@/types/contexts'; diff --git a/src/plugins/discord/index.ts b/src/plugins/discord/index.ts index bfb3e4c7..f1c7b361 100644 --- a/src/plugins/discord/index.ts +++ b/src/plugins/discord/index.ts @@ -1,6 +1,6 @@ import { createPlugin } from '@/utils'; -import { onLoad, onUnload } from '@/plugins/discord/main'; -import {onMenu} from "@/plugins/discord/menu"; +import { onLoad, onUnload } from './main'; +import { onMenu } from './menu'; export type DiscordPluginConfig = { enabled: boolean; diff --git a/src/plugins/discord/main.ts b/src/plugins/discord/main.ts index 916f972b..c4163f9a 100644 --- a/src/plugins/discord/main.ts +++ b/src/plugins/discord/main.ts @@ -4,7 +4,7 @@ import { dev } from 'electron-is'; 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'; diff --git a/src/plugins/discord/menu.ts b/src/plugins/discord/menu.ts index 0dc752cc..0e82afc4 100644 --- a/src/plugins/discord/menu.ts +++ b/src/plugins/discord/menu.ts @@ -3,12 +3,11 @@ import prompt from 'custom-electron-prompt'; import { clear, connect, isConnected, registerRefresh } from './main'; import { singleton } from '@/providers/decorators'; +import promptOptions from '@/providers/prompt-options'; import { setMenuOptions } from '@/config/plugins'; import { MenuContext } from '@/types/contexts'; import { DiscordPluginConfig } from '@/plugins/discord/index'; -import promptOptions from '../../providers/prompt-options'; - import type { MenuTemplate } from '@/menu'; const registerRefreshOnce = singleton((refreshMenu: () => void) => { diff --git a/src/plugins/downloader/index.ts b/src/plugins/downloader/index.ts index 6660d77d..93a0ee5f 100644 --- a/src/plugins/downloader/index.ts +++ b/src/plugins/downloader/index.ts @@ -3,8 +3,8 @@ import { DefaultPresetList, Preset } from './types'; import style from './style.css?inline'; import { createPlugin } from '@/utils'; -import { onConfigChange, onMainLoad } from '@/plugins/downloader/main'; -import { onPlayerApiReady, onRendererLoad } from '@/plugins/downloader/renderer'; +import { onConfigChange, onMainLoad } from './main'; +import { onPlayerApiReady, onRendererLoad } from './renderer'; export type DownloaderPluginConfig = { enabled: boolean; @@ -15,17 +15,19 @@ export type DownloaderPluginConfig = { 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({ name: 'Downloader', restartNeeded: true, - config: { - enabled: false, - downloadFolder: undefined, - selectedPreset: 'mp3 (256kbps)', // Selected preset - customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets - skipExisting: false, - playlistMaxItems: undefined, - } as DownloaderPluginConfig, + config: defaultConfig, stylesheets: [style], backend: { start: onMainLoad, diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts index e793aa42..7be07ce1 100644 --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -37,7 +37,7 @@ import { BackendContext } from '@/types/contexts'; 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 PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage'; @@ -90,7 +90,7 @@ export const getCookieFromWindow = async (win: BrowserWindow) => { .join(';'); }; -let config: DownloaderPluginConfig; +let config: DownloaderPluginConfig = defaultConfig; export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext) => { win = _win; diff --git a/src/plugins/in-app-menu/index.ts b/src/plugins/in-app-menu/index.ts index 52631d54..e7c8bb71 100644 --- a/src/plugins/in-app-menu/index.ts +++ b/src/plugins/in-app-menu/index.ts @@ -1,8 +1,8 @@ import titlebarStyle from './titlebar.css?inline'; import { createPlugin } from '@/utils'; -import { onMainLoad } from '@/plugins/in-app-menu/main'; -import { onMenu } from '@/plugins/in-app-menu/menu'; -import { onPlayerApiReady, onRendererLoad } from '@/plugins/in-app-menu/renderer'; +import { onMainLoad } from './main'; +import { onMenu } from './menu'; +import { onPlayerApiReady, onRendererLoad } from './renderer'; export interface InAppMenuConfig { enabled: boolean; diff --git a/src/plugins/last-fm/index.ts b/src/plugins/last-fm/index.ts index c1026f03..54b7e11a 100644 --- a/src/plugins/last-fm/index.ts +++ b/src/plugins/last-fm/index.ts @@ -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 { enabled: boolean; @@ -30,7 +32,7 @@ export interface LastFmPluginConfig { secret: string; } -const builder = createPluginBuilder('last-fm', { +export default createPlugin({ name: 'Last.fm', restartNeeded: true, config: { @@ -39,12 +41,34 @@ const builder = createPluginBuilder('last-fm', { api_key: '04d76faaac8726e60988e14c105d421a', secret: 'a5d2a36fdf64819290f6982481eaffa2', } 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 { - interface PluginBuilderList { - [builder.id]: typeof builder; + if (!config.session_key) { + // Not authenticated + 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); + } + } + }); } -} +}); diff --git a/src/plugins/last-fm/main.ts b/src/plugins/last-fm/main.ts index 0ef0c941..07e887c3 100644 --- a/src/plugins/last-fm/main.ts +++ b/src/plugins/last-fm/main.ts @@ -2,10 +2,8 @@ import crypto from 'node:crypto'; import { net, shell } from 'electron'; -import builder, { type LastFmPluginConfig } from './index'; - -import { setOptions } from '../../config/plugins'; -import registerCallback, { type SongInfo } from '../../providers/song-info'; +import type { LastFmPluginConfig } from './index'; +import type { SongInfo } from '@/providers/song-info'; interface LastFmData { method: string, @@ -53,7 +51,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => { keys.sort(); let sig = ''; for (const key of keys) { - if (String(key) === 'format') { + if (key === 'format') { 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}`); }; -const getAndSetSessionKey = async (config: LastFmPluginConfig) => { +type SetConfType = (conf: Partial>) => (void | Promise); + +export const getAndSetSessionKey = async (config: LastFmPluginConfig, setConfig: SetConfType) => { // Get and store the session key const data = { api_key: config.api_key, @@ -102,19 +102,19 @@ const getAndSetSessionKey = async (config: LastFmPluginConfig) => { if (json.error) { config.token = await createToken(config); await authenticate(config); - setOptions('last-fm', config); + setConfig(config); } if (json.session) { config.session_key = json.session.key; } - setOptions('last-fm', config); + setConfig(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 if (!config.session_key) { - await getAndSetSessionKey(config); + await getAndSetSessionKey(config, setConfig); } const postData: LastFmSongData = { @@ -143,58 +143,24 @@ const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, config.session_key = undefined; config.token = await createToken(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 const data = { method: 'track.scrobble', 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 const data = { 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); - } - } - }); - } -})); diff --git a/src/plugins/lumiastream/index.ts b/src/plugins/lumiastream/index.ts index e8a2db55..50e489d9 100644 --- a/src/plugins/lumiastream/index.ts +++ b/src/plugins/lumiastream/index.ts @@ -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]', restartNeeded: true, config: { 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 { - interface PluginBuilderList { - [builder.id]: typeof builder; + 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}` + ); + }); + }; + + 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); + }); } -} +}); diff --git a/src/plugins/lumiastream/main.ts b/src/plugins/lumiastream/main.ts deleted file mode 100644 index e98d8bf7..00000000 --- a/src/plugins/lumiastream/main.ts +++ /dev/null @@ -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); - }); - } - }; -}); - - diff --git a/src/plugins/lyrics-genius/index.ts b/src/plugins/lyrics-genius/index.ts index 754db66d..71318b54 100644 --- a/src/plugins/lyrics-genius/index.ts +++ b/src/plugins/lyrics-genius/index.ts @@ -1,26 +1,41 @@ import style from './style.css?inline'; - -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; +import { onConfigChange, onMainLoad } from './main'; +import { onRendererLoad } from './renderer'; export type LyricsGeniusPluginConfig = { enabled: boolean; romanizedLyrics: boolean; } -const builder = createPluginBuilder('lyrics-genius', { +export default createPlugin({ name: 'Lyrics Genius', restartNeeded: true, config: { enabled: false, romanizedLyrics: false, } 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; - } -} diff --git a/src/plugins/lyrics-genius/main.ts b/src/plugins/lyrics-genius/main.ts index 0c43877c..bd7840dd 100644 --- a/src/plugins/lyrics-genius/main.ts +++ b/src/plugins/lyrics-genius/main.ts @@ -3,33 +3,31 @@ import is from 'electron-is'; import { convert } from 'html-to-text'; 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; let revRomanized = false; -export default builder.createMain(({ handle, getConfig }) =>{ - return { - async onLoad() { - const config = await getConfig(); +export const onMainLoad = async ({ ipc, getConfig }: BackendContext) => { + const config = await getConfig(); - if (config.romanizedLyrics) { - revRomanized = true; - } + if (config.romanizedLyrics) { + revRomanized = true; + } - handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => { - const metadata = extractedSongInfo; - return await fetchFromGenius(metadata); - }); - }, - onConfigChange(newConfig) { - revRomanized = newConfig.romanizedLyrics; - } - }; -}); + ipc.handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => { + const metadata = extractedSongInfo; + return await fetchFromGenius(metadata); + }); +}; + +export const onConfigChange = (newConfig: LyricsGeniusPluginConfig) => { + revRomanized = newConfig.romanizedLyrics; +}; export const fetchFromGenius = async (metadata: SongInfo) => { const songTitle = `${cleanupName(metadata.title)}`; diff --git a/src/plugins/lyrics-genius/menu.ts b/src/plugins/lyrics-genius/menu.ts deleted file mode 100644 index d6339d5e..00000000 --- a/src/plugins/lyrics-genius/menu.ts +++ /dev/null @@ -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, - }); - }, - }, - ]; -}); diff --git a/src/plugins/lyrics-genius/renderer.ts b/src/plugins/lyrics-genius/renderer.ts index d9c8f4ce..2f74eb41 100644 --- a/src/plugins/lyrics-genius/renderer.ts +++ b/src/plugins/lyrics-genius/renderer.ts @@ -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 default builder.createRenderer(({ on, invoke }) => ({ - onLoad() { - const setLyrics = (lyricsContainer: Element, lyrics: string | null) => { - lyricsContainer.innerHTML = ` +export const onRendererLoad = ({ ipc: { invoke, on } }: RendererContext) => { + const setLyrics = (lyricsContainer: Element, lyrics: string | null) => { + lyricsContainer.innerHTML = `
${lyrics?.replaceAll(/\r\n|\r|\n/g, '
') ?? 'Could not retrieve lyrics from genius'}
@@ -13,97 +12,96 @@ export default builder.createRenderer(({ on, invoke }) => ({ `; - if (lyrics) { - const footer = lyricsContainer.querySelector('.footer'); + if (lyrics) { + const footer = lyricsContainer.querySelector('.footer'); - if (footer) { - footer.textContent = 'Source: Genius'; - } + if (footer) { + footer.textContent = 'Source: Genius'; } - }; + } + }; - let unregister: (() => void) | null = null; + let unregister: (() => void) | null = null; - on('update-song-info', (extractedSongInfo: SongInfo) => { - unregister?.(); + on('update-song-info', (extractedSongInfo: SongInfo) => { + unregister?.(); - setTimeout(async () => { - const tabList = document.querySelectorAll('tp-yt-paper-tab'); - const tabs = { - upNext: tabList[0], - lyrics: tabList[1], - discover: tabList[2], - }; + setTimeout(async () => { + const tabList = document.querySelectorAll('tp-yt-paper-tab'); + const tabs = { + upNext: tabList[0], + lyrics: tabList[1], + discover: tabList[2], + }; - // Check if disabled - if (!tabs.lyrics?.hasAttribute('disabled')) return; + // Check if disabled + if (!tabs.lyrics?.hasAttribute('disabled')) return; - const lyrics = await invoke( - 'search-genius-lyrics', - extractedSongInfo, + const lyrics = await invoke( + 'search-genius-lyrics', + 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) { - // Delete previous lyrics if tab is open and couldn't get new lyrics - tabs.upNext.click(); + if (lyricsContainer) { + callback?.(); - return; + setLyrics(lyricsContainer, lyrics); + applyLyricsTabState(); } - - if (window.electronIs.dev()) { - console.log('Fetched lyrics from Genius'); + }; + 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 tryToInjectLyric = (callback?: () => void) => { - const lyricsContainer = document.querySelector( - '[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer', - ); + const observer = new MutationObserver((_, observer) => { + tryToInjectLyric(() => observer.disconnect()); + }); - if (lyricsContainer) { - callback?.(); + observer.observe(tabContainer, { + attributes: true, + childList: true, + subtree: true, + }); + }; - setLyrics(lyricsContainer, lyrics); - 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; + applyLyricsTabState(); - const observer = new MutationObserver((_, observer) => { - tryToInjectLyric(() => observer.disconnect()); - }); + tabs.discover.addEventListener('click', applyLyricsTabState); + tabs.lyrics.addEventListener('click', lyricsTabHandler); + tabs.upNext.addEventListener('click', applyLyricsTabState); - observer.observe(tabContainer, { - attributes: true, - childList: true, - subtree: true, - }); - }; + tryToInjectLyric(); - applyLyricsTabState(); - - tabs.discover.addEventListener('click', applyLyricsTabState); - tabs.lyrics.addEventListener('click', lyricsTabHandler); - tabs.upNext.addEventListener('click', applyLyricsTabState); - - tryToInjectLyric(); - - unregister = () => { - tabs.discover.removeEventListener('click', applyLyricsTabState); - tabs.lyrics.removeEventListener('click', lyricsTabHandler); - tabs.upNext.removeEventListener('click', applyLyricsTabState); - }; - }, 500); - }); - } -})); + unregister = () => { + tabs.discover.removeEventListener('click', applyLyricsTabState); + tabs.lyrics.removeEventListener('click', lyricsTabHandler); + tabs.upNext.removeEventListener('click', applyLyricsTabState); + }; + }, 500); + }); +}; diff --git a/src/plugins/navigation/index.ts b/src/plugins/navigation/index.ts index aa17cfc2..34195079 100644 --- a/src/plugins/navigation/index.ts +++ b/src/plugins/navigation/index.ts @@ -1,20 +1,24 @@ 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', restartNeeded: true, config: { 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; - } -} diff --git a/src/plugins/navigation/renderer.ts b/src/plugins/navigation/renderer.ts deleted file mode 100644 index 40b5c8d9..00000000 --- a/src/plugins/navigation/renderer.ts +++ /dev/null @@ -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); - } - } - }; -}); diff --git a/src/plugins/no-google-login/index.ts b/src/plugins/no-google-login/index.ts index c39f528c..db8320ba 100644 --- a/src/plugins/no-google-login/index.ts +++ b/src/plugins/no-google-login/index.ts @@ -1,20 +1,46 @@ import style from './style.css?inline'; +import { createPlugin } from '@/utils'; -import { createPluginBuilder } from '../utils/builder'; - -const builder = createPluginBuilder('no-google-login', { +export default createPlugin({ name: 'Remove Google Login', restartNeeded: true, config: { 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 { - interface PluginBuilderList { - [builder.id]: typeof builder; + // 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, + }); } -} +}); diff --git a/src/plugins/no-google-login/main.ts b/src/plugins/no-google-login/main.ts deleted file mode 100644 index 71af5356..00000000 --- a/src/plugins/no-google-login/main.ts +++ /dev/null @@ -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); -}; diff --git a/src/plugins/no-google-login/renderer.ts b/src/plugins/no-google-login/renderer.ts deleted file mode 100644 index 7b7a1989..00000000 --- a/src/plugins/no-google-login/renderer.ts +++ /dev/null @@ -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, - }); - } -})); diff --git a/src/plugins/notifications/index.ts b/src/plugins/notifications/index.ts index 5db116d3..cb927d9a 100644 --- a/src/plugins/notifications/index.ts +++ b/src/plugins/notifications/index.ts @@ -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 { enabled: boolean; unpauseNotification: boolean; + /** + * Has effect only on Linux + */ urgency: 'low' | 'normal' | 'critical'; + /** + * the following has effect only on Windows + */ interactive: boolean; + /** + * See plugins/notifications/utils for more info + */ toastStyle: number; refreshOnPlayPause: boolean; trayControls: 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', restartNeeded: true, - config: { - enabled: false, - unpauseNotification: false, - urgency: 'normal', // Has effect only on Linux - // the following has effect only on Windows - interactive: true, - toastStyle: 1, // See plugins/notifications/utils for more info - refreshOnPlayPause: false, - trayControls: true, - hideButtonText: false, - } as NotificationsPluginConfig, + config: defaultConfig, + menu: onMenu, + backend: { + start: onMainLoad, + onConfigChange, + }, }); - -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; - } -} diff --git a/src/plugins/notifications/interactive.ts b/src/plugins/notifications/interactive.ts index a672fceb..2814e95c 100644 --- a/src/plugins/notifications/interactive.ts +++ b/src/plugins/notifications/interactive.ts @@ -1,21 +1,20 @@ 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 getSongControls from '../../providers/song-controls'; -import registerCallback, { SongInfo } from '../../providers/song-info'; -import { changeProtocolHandler } from '../../providers/protocol-handler'; -import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray'; -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 getSongControls from '@/providers/song-controls'; +import registerCallback, { SongInfo } from '@/providers/song-info'; +import { changeProtocolHandler } from '@/providers/protocol-handler'; +import { setTrayOnClick, setTrayOnDoubleClick } from '@/tray'; +import { mediaIcons } from '@/types/media-icons'; import type { NotificationsPluginConfig } from './index'; +import type { BackendContext } from '@/types/contexts'; let songControls: ReturnType; let savedNotification: Notification | undefined; @@ -25,7 +24,7 @@ type Accessor = () => T; export default ( win: BrowserWindow, config: Accessor, - { on, send }: MainPluginContext, + { ipc: { on, send } }: BackendContext, ) => { const sendNotification = (songInfo: SongInfo) => { const iconSrc = notificationImage(songInfo, config()); diff --git a/src/plugins/notifications/main.ts b/src/plugins/notifications/main.ts index 4eb8cf45..04c4597e 100644 --- a/src/plugins/notifications/main.ts +++ b/src/plugins/notifications/main.ts @@ -5,11 +5,12 @@ import is from 'electron-is'; import { notificationImage } from './utils'; 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) => { // Send the notification @@ -42,17 +43,14 @@ const setup = () => { }); }; -export default builder.createMain((context) => { - return { - async onLoad(win) { - config = await context.getConfig(); +export const onMainLoad = async (context: BackendContext) => { + config = await context.getConfig(); - // Register the callback for new song information - if (is.windows() && config.interactive) interactive(win, () => config, context); - else setup(); - }, - onConfigChange(newConfig) { - config = newConfig; - } - }; -}); + // Register the callback for new song information + if (is.windows() && config.interactive) interactive(context.window, () => config, context); + else setup(); +}; + +export const onConfigChange = (newConfig: NotificationsPluginConfig) => { + config = newConfig; +}; diff --git a/src/plugins/notifications/menu.ts b/src/plugins/notifications/menu.ts index 77887777..1b603049 100644 --- a/src/plugins/notifications/menu.ts +++ b/src/plugins/notifications/menu.ts @@ -1,14 +1,14 @@ import is from 'electron-is'; - import { MenuItem } from 'electron'; 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): Promise => { const config = await getConfig(); const getToastStyleMenuItems = (options: NotificationsPluginConfig) => { @@ -25,7 +25,7 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => { } return array as Electron.MenuItemConstructorOptions[]; - } + }; const getMenu = (): MenuTemplate => { if (is.linux()) { @@ -92,4 +92,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => { click: (item) => setConfig({ unpauseNotification: item.checked }), }, ]; -}); +}; diff --git a/src/plugins/notifications/utils.ts b/src/plugins/notifications/utils.ts index 98aac1c8..1c88fe63 100644 --- a/src/plugins/notifications/utils.ts +++ b/src/plugins/notifications/utils.ts @@ -3,12 +3,12 @@ import fs from 'node:fs'; import { app, NativeImage } from 'electron'; -import { cache } from '../../providers/decorators'; -import { SongInfo } from '../../providers/song-info'; +import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack'; -import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack'; -import {NotificationsPluginConfig} from "./index"; +import { cache } from '@/providers/decorators'; +import { SongInfo } from '@/providers/song-info'; +import type { NotificationsPluginConfig } from './index'; const userData = app.getPath('userData'); const temporaryIcon = path.join(userData, 'tempIcon.png'); diff --git a/src/plugins/picture-in-picture/index.ts b/src/plugins/picture-in-picture/index.ts index 6783aa24..198b4fb6 100644 --- a/src/plugins/picture-in-picture/index.ts +++ b/src/plugins/picture-in-picture/index.ts @@ -1,6 +1,9 @@ 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 = { 'enabled': boolean; @@ -14,7 +17,7 @@ export type PictureInPicturePluginConfig = { 'useNativePiP': boolean; } -const builder = createPluginBuilder('picture-in-picture', { +export default createPlugin({ name: 'Picture In Picture', restartNeeded: true, config: { @@ -28,13 +31,15 @@ const builder = createPluginBuilder('picture-in-picture', { 'isInPiP': false, 'useNativePiP': true, } as PictureInPicturePluginConfig, - styles: [style], -}); + stylesheets: [style], + menu: onMenu, -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; + backend: { + start: onMainLoad, + onConfigChange, + }, + renderer: { + start: onRendererLoad, + onPlayerApiReady, } -} +}); diff --git a/src/plugins/picture-in-picture/main.ts b/src/plugins/picture-in-picture/main.ts index 4300ceac..abbea0d7 100644 --- a/src/plugins/picture-in-picture/main.ts +++ b/src/plugins/picture-in-picture/main.ts @@ -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) => { let isInPiP = false; let originalPosition: number[]; let originalSize: number[]; let originalFullScreen: boolean; let originalMaximized: boolean; - let win: BrowserWindow; - - let config: PictureInPicturePluginConfig; - const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10]; const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275]; @@ -25,59 +21,59 @@ export default builder.createMain(({ getConfig, setConfig, send, handle, on }) = setConfig({ isInPiP }); if (isInPiP) { - originalFullScreen = win.isFullScreen(); + originalFullScreen = window.isFullScreen(); if (originalFullScreen) { - win.setFullScreen(false); + window.setFullScreen(false); } - originalMaximized = win.isMaximized(); + originalMaximized = window.isMaximized(); if (originalMaximized) { - win.unmaximize(); + window.unmaximize(); } - originalPosition = win.getPosition(); - originalSize = win.getSize(); + originalPosition = window.getPosition(); + originalSize = window.getSize(); handle('before-input-event', blockShortcutsInPiP); - win.setMaximizable(false); - win.setFullScreenable(false); + window.setMaximizable(false); + window.setFullScreenable(false); send('pip-toggle', true); app.dock?.hide(); - win.setVisibleOnAllWorkspaces(true, { + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, }); app.dock?.show(); if (config.alwaysOnTop) { - win.setAlwaysOnTop(true, 'screen-saver', 1); + window.setAlwaysOnTop(true, 'screen-saver', 1); } } else { - win.webContents.removeListener('before-input-event', blockShortcutsInPiP); - win.setMaximizable(true); - win.setFullScreenable(true); + window.webContents.removeListener('before-input-event', blockShortcutsInPiP); + window.setMaximizable(true); + window.setFullScreenable(true); send('pip-toggle', false); - win.setVisibleOnAllWorkspaces(false); - win.setAlwaysOnTop(false); + window.setVisibleOnAllWorkspaces(false); + window.setAlwaysOnTop(false); if (originalFullScreen) { - win.setFullScreen(true); + window.setFullScreen(true); } if (originalMaximized) { - win.maximize(); + window.maximize(); } } const [x, y] = isInPiP ? pipPosition() : originalPosition; const [w, h] = isInPiP ? pipSize() : originalSize; - win.setPosition(x, y); - win.setSize(w, h); + window.setPosition(x, y); + window.setSize(w, h); - win.setWindowButtonVisibility?.(!isInPiP); + window.setWindowButtonVisibility?.(!isInPiP); }; const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => { @@ -91,30 +87,25 @@ export default builder.createMain(({ getConfig, setConfig, send, handle, on }) = } }; - return ({ - async onLoad(window) { - config ??= await getConfig(); - win ??= window; - setConfig({ isInPiP }); - on('picture-in-picture', () => { - togglePiP(); - }); + config ??= await getConfig(); + setConfig({ isInPiP }); + on('picture-in-picture', () => { + togglePiP(); + }); - window.on('move', () => { - if (config.isInPiP && !config.useNativePiP) { - 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('move', () => { + if (config.isInPiP && !config.useNativePiP) { + setConfig({ 'pip-position': window.getPosition() as [number, number] }); } }); -}); + window.on('resize', () => { + if (config.isInPiP && !config.useNativePiP) { + setConfig({ 'pip-size': window.getSize() as [number, number] }); + } + }); +}; + +export const onConfigChange = (newConfig: PictureInPicturePluginConfig) => { + config = newConfig; +}; diff --git a/src/plugins/picture-in-picture/menu.ts b/src/plugins/picture-in-picture/menu.ts index e06a2a59..35f55619 100644 --- a/src/plugins/picture-in-picture/menu.ts +++ b/src/plugins/picture-in-picture/menu.ts @@ -1,11 +1,14 @@ 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): Promise => { const config = await getConfig(); return [ @@ -71,4 +74,4 @@ export default builder.createMenu(async ({ window, getConfig, setConfig }) => { }, }, ]; -}); +}; diff --git a/src/plugins/picture-in-picture/renderer.ts b/src/plugins/picture-in-picture/renderer.ts index 2f6b7a02..853dcb8e 100644 --- a/src/plugins/picture-in-picture/renderer.ts +++ b/src/plugins/picture-in-picture/renderer.ts @@ -3,12 +3,13 @@ import keyEventAreEqual from 'keyboardevents-areequal'; 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 type { PictureInPicturePluginConfig } from './index'; +import type { RendererContext } from '@/types/contexts'; + function $(selector: string) { return document.querySelector(selector); } @@ -133,42 +134,38 @@ const listenForToggle = () => { }); }; +export const onRendererLoad = async ({ getConfig }: RendererContext) => { + const config = await getConfig(); -export default builder.createRenderer(({ getConfig }) => { - return { - async onLoad() { - const config = await getConfig(); + useNativePiP = config.useNativePiP; - useNativePiP = config.useNativePiP; - - if (config.hotkey) { - const hotkeyEvent = toKeyEvent(config.hotkey); - window.addEventListener('keydown', (event) => { - if ( - keyEventAreEqual(event, hotkeyEvent) - && !$('ytmusic-search-box')?.opened - ) { - togglePictureInPicture(); - } - }); + if (config.hotkey) { + const hotkeyEvent = toKeyEvent(config.hotkey); + window.addEventListener('keydown', (event) => { + if ( + keyEventAreEqual(event, hotkeyEvent) + && !$('ytmusic-search-box')?.opened + ) { + togglePictureInPicture(); } - }, - onPlayerApiReady() { - listenForToggle(); + }); + } +}; - cloneButton('.player-minimize-button')?.addEventListener('click', async () => { - await togglePictureInPicture(); - setTimeout(() => $('#player')?.click()); - }); +export const onPlayerApiReady = () => { + listenForToggle(); - // Allows easily closing the menu by programmatically clicking outside of it - $('#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, - }); - }, - }; -}); + cloneButton('.player-minimize-button')?.addEventListener('click', async () => { + await togglePictureInPicture(); + setTimeout(() => $('#player')?.click()); + }); + + // Allows easily closing the menu by programmatically clicking outside of it + $('#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, + }); +}; diff --git a/src/plugins/playback-speed/index.ts b/src/plugins/playback-speed/index.ts index cd898898..bcc080e6 100644 --- a/src/plugins/playback-speed/index.ts +++ b/src/plugins/playback-speed/index.ts @@ -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', restartNeeded: false, config: { enabled: false, }, -}); - -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; + renderer: { + stop: onUnload, + onPlayerApiReady, } -} +}); diff --git a/src/plugins/playback-speed/renderer.ts b/src/plugins/playback-speed/renderer.ts index 6725432b..cf079e78 100644 --- a/src/plugins/playback-speed/renderer.ts +++ b/src/plugins/playback-speed/renderer.ts @@ -1,15 +1,9 @@ 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 { singleton } from '../../providers/decorators'; - - -function $(selector: string) { - return document.querySelector(selector); -} const slider = ElementFromHtml(sliderHTML); @@ -21,12 +15,12 @@ const MAX_PLAYBACK_SPEED = 16; let playbackSpeed = 1; const updatePlayBackSpeed = () => { - const videoElement = $('video'); + const videoElement = document.querySelector('video'); if (videoElement) { videoElement.playbackRate = playbackSpeed; } - const playbackSpeedElement = $('#playback-speed-value'); + const playbackSpeedElement = document.querySelector('#playback-speed-value'); if (playbackSpeedElement) { playbackSpeedElement.innerHTML = String(playbackSpeed); } @@ -44,7 +38,7 @@ const immediateValueChangedListener = (e: Event) => { }; const setupSliderListener = singleton(() => { - $('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener); + document.querySelector('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener); }); const observePopupContainer = () => { @@ -64,7 +58,7 @@ const observePopupContainer = () => { } }); - const popupContainer = $('ytmusic-popup-container'); + const popupContainer = document.querySelector('ytmusic-popup-container'); if (popupContainer) { observer.observe(popupContainer, { childList: true, @@ -74,7 +68,7 @@ const observePopupContainer = () => { }; const observeVideo = () => { - const video = $('video'); + const video = document.querySelector('video'); if (video) { video.addEventListener('ratechange', forcePlaybackRate); video.addEventListener('srcChanged', forcePlaybackRate); @@ -95,7 +89,7 @@ const wheelEventListener = (e: WheelEvent) => { updatePlayBackSpeed(); // Update slider position - const playbackSpeedSilder = $('#playback-speed-slider'); + const playbackSpeedSilder = document.querySelector('#playback-speed-slider'); if (playbackSpeedSilder) { playbackSpeedSilder.value = playbackSpeed; } @@ -114,22 +108,19 @@ function forcePlaybackRate(e: Event) { } } -export default builder.createRenderer(() => { - return { - onPlayerApiReady() { - observePopupContainer(); - observeVideo(); - setupWheelListener(); - }, - onUnload() { - const video = $('video'); - if (video) { - video.removeEventListener('ratechange', forcePlaybackRate); - video.removeEventListener('srcChanged', forcePlaybackRate); - } - slider.removeEventListener('wheel', wheelEventListener); - getSongMenu()?.removeChild(slider); - $('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener); - } - }; -}); +export const onPlayerApiReady = () => { + observePopupContainer(); + observeVideo(); + setupWheelListener(); +}; + +export const onUnload = () => { + const video = document.querySelector('video'); + if (video) { + video.removeEventListener('ratechange', forcePlaybackRate); + video.removeEventListener('srcChanged', forcePlaybackRate); + } + slider.removeEventListener('wheel', wheelEventListener); + getSongMenu()?.removeChild(slider); + document.querySelector('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener); +}; diff --git a/src/plugins/precise-volume/index.ts b/src/plugins/precise-volume/index.ts index e639ae6c..fa55e039 100644 --- a/src/plugins/precise-volume/index.ts +++ b/src/plugins/precise-volume/index.ts @@ -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 = { enabled: boolean; @@ -13,7 +19,7 @@ export type PreciseVolumePluginConfig = { savedVolume: number | undefined; }; -const builder = createPluginBuilder('precise-volume', { +export default createPlugin({ name: 'Precise Volume', restartNeeded: true, config: { @@ -26,13 +32,107 @@ const builder = createPluginBuilder('precise-volume', { }, savedVolume: undefined, // Plugin save volume between session here } as PreciseVolumePluginConfig, - styles: [hudStyle], -}); + stylesheets: [hudStyle], + menu: async ({ setConfig, getConfig, window }) => { + const config = await getConfig(); -export default builder; + function changeOptions(changedOptions: Partial, options: PreciseVolumePluginConfig) { + for (const option in changedOptions) { + // HACK: Weird TypeScript error + (options as Record)[option] = (changedOptions as Record)[option]; + } -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; + 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(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, } -} +}); diff --git a/src/plugins/precise-volume/main.ts b/src/plugins/precise-volume/main.ts deleted file mode 100644 index 0582a9ab..00000000 --- a/src/plugins/precise-volume/main.ts +++ /dev/null @@ -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)); - } - }, -})); diff --git a/src/plugins/precise-volume/menu.ts b/src/plugins/precise-volume/menu.ts deleted file mode 100644 index f449c0a5..00000000 --- a/src/plugins/precise-volume/menu.ts +++ /dev/null @@ -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, options: PreciseVolumePluginConfig, win: BrowserWindow) { - for (const option in changedOptions) { - // HACK: Weird TypeScript error - (options as Record)[option] = (changedOptions as Record)[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), - }, - ]; -}); diff --git a/src/plugins/precise-volume/override.ts b/src/plugins/precise-volume/override.ts index bffef1e8..0b001824 100644 --- a/src/plugins/precise-volume/override.ts +++ b/src/plugins/precise-volume/override.ts @@ -1,5 +1,4 @@ /* what */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ const ignored = { id: ['volume-slider', 'expand-volume-slider'], @@ -9,7 +8,8 @@ const ignored = { function overrideAddEventListener() { // YO WHAT ARE YOU DOING NOW?!?! // 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 Element.prototype._addEventListener = Element.prototype.addEventListener; // Override addEventListener to Ignore specific events in volume-slider diff --git a/src/plugins/precise-volume/renderer.ts b/src/plugins/precise-volume/renderer.ts index bd9d2905..f191cb6b 100644 --- a/src/plugins/precise-volume/renderer.ts +++ b/src/plugins/precise-volume/renderer.ts @@ -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 { YoutubePlayer } from '../../types/youtube-player'; +import type { RendererContext } from '@/types/contexts'; +import type { YoutubePlayer } from '@/types/youtube-player'; function $(selector: string) { return document.querySelector(selector); @@ -23,12 +22,14 @@ export const moveVolumeHud = debounce((showVideo: boolean) => { : '0'; }, 250); -export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { - let options: PreciseVolumePluginConfig = await getConfig(); +let options: PreciseVolumePluginConfig; + +export const onPlayerApiReady = (playerApi: YoutubePlayer, context: RendererContext) => { + api = playerApi; // Without this function it would rewrite config 20 time when volume change by 20 const writeOptions = debounce(() => { - setConfig(options); + context.setConfig(options); }, 1000); 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 { - onLoad() { - overrideListener(); - }, - onPlayerApiReady(playerApi) { - api = playerApi; + firstRun(); +}; - on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease)); - on('setVolume', (value: number) => setVolume(value)); - firstRun(); - }, - onConfigChange(config) { - options = config; - } - }; -}); +export const onConfigChange = (config: PreciseVolumePluginConfig) => { + options = config; +}; diff --git a/src/plugins/quality-changer/index.ts b/src/plugins/quality-changer/index.ts index 38f7d852..f29ffd31 100644 --- a/src/plugins/quality-changer/index.ts +++ b/src/plugins/quality-changer/index.ts @@ -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', restartNeeded: false, config: { 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('#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; - } -} diff --git a/src/plugins/quality-changer/main.ts b/src/plugins/quality-changer/main.ts deleted file mode 100644 index 9286a8d4..00000000 --- a/src/plugins/quality-changer/main.ts +++ /dev/null @@ -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, - })); - } -})); diff --git a/src/plugins/quality-changer/renderer.ts b/src/plugins/quality-changer/renderer.ts deleted file mode 100644 index 896ed88e..00000000 --- a/src/plugins/quality-changer/renderer.ts +++ /dev/null @@ -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); - } - }; -}); diff --git a/src/plugins/shortcuts/index.ts b/src/plugins/shortcuts/index.ts index 4f02c65f..c1436566 100644 --- a/src/plugins/shortcuts/index.ts +++ b/src/plugins/shortcuts/index.ts @@ -1,4 +1,6 @@ -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; +import { onMainLoad } from './main'; +import { onMenu } from './menu'; export type ShortcutMappingType = { previous: string; @@ -12,7 +14,7 @@ export type ShortcutsPluginConfig = { local: ShortcutMappingType; } -const builder = createPluginBuilder('shortcuts', { +export default createPlugin({ name: 'Shortcuts (& MPRIS)', restartNeeded: true, config: { @@ -29,12 +31,7 @@ const builder = createPluginBuilder('shortcuts', { next: '', }, } as ShortcutsPluginConfig, + menu: onMenu, + + backend: onMainLoad, }); - -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; - } -} diff --git a/src/plugins/shortcuts/main.ts b/src/plugins/shortcuts/main.ts index 6e8319b2..18480062 100644 --- a/src/plugins/shortcuts/main.ts +++ b/src/plugins/shortcuts/main.ts @@ -1,12 +1,13 @@ import { BrowserWindow, globalShortcut } from 'electron'; import is from 'electron-is'; -import electronLocalshortcut from 'electron-localshortcut'; +import { register as registerElectronLocalShortcut } from 'electron-localshortcut'; 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) { @@ -16,62 +17,58 @@ function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: st } function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) { - electronLocalshortcut.register(win, shortcut, () => { + registerElectronLocalShortcut(win, shortcut, () => { action(win.webContents); }); } -export default builder.createMain(({ getConfig }) => { - return { - async onLoad(win) { - const config = await getConfig(); +export const onMainLoad = async ({ getConfig, window }: BackendContext) => { + const config = await getConfig(); - const songControls = getSongControls(win); - const { playPause, next, previous, search } = songControls; + const songControls = getSongControls(window); + const { playPause, next, previous, search } = songControls; - if (config.overrideMediaKeys) { - _registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause); - _registerGlobalShortcut(win.webContents, 'MediaNextTrack', next); - _registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous); + if (config.overrideMediaKeys) { + _registerGlobalShortcut(window.webContents, 'MediaPlayPause', playPause); + _registerGlobalShortcut(window.webContents, 'MediaNextTrack', next); + _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); - _registerLocalShortcut(win, 'CommandOrControl+L', search); - - if (is.linux()) { - registerMPRIS(win); + console.debug(`Registering ${type} shortcut`, container[action], ':', action); + const actionCallback: () => void = songControls[action]; + if (typeof actionCallback !== 'function') { + console.warn('Invalid action', action); + continue; } - 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 - } - - 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); - } - } + if (type === 'global') { + _registerGlobalShortcut(window.webContents, container[action], actionCallback); + } else { // Type === "local" + _registerLocalShortcut(window, local[action], actionCallback); } } - }; -}); + } +}; diff --git a/src/plugins/shortcuts/menu.ts b/src/plugins/shortcuts/menu.ts index 9065866d..47d5a931 100644 --- a/src/plugins/shortcuts/menu.ts +++ b/src/plugins/shortcuts/menu.ts @@ -1,12 +1,13 @@ 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 { 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): Promise => { const config = await getConfig(); /** @@ -52,4 +53,4 @@ export default builder.createMenu(async ({ window, getConfig, setConfig }) => { click: (item) => setConfig({ overrideMediaKeys: item.checked }), }, ]; -}); +}; diff --git a/src/plugins/shortcuts/mpris.ts b/src/plugins/shortcuts/mpris.ts index 4b68d720..115d2d74 100644 --- a/src/plugins/shortcuts/mpris.ts +++ b/src/plugins/shortcuts/mpris.ts @@ -2,9 +2,9 @@ import { BrowserWindow, ipcMain } from 'electron'; import mpris, { Track } from '@jellybrick/mpris-service'; -import registerCallback from '../../providers/song-info'; -import getSongControls from '../../providers/song-controls'; -import config from '../../config'; +import registerCallback from '@/providers/song-info'; +import getSongControls from '@/providers/song-controls'; +import config from '@/config'; function setupMPRIS() { const instance = new mpris({ diff --git a/src/plugins/skip-silences/index.ts b/src/plugins/skip-silences/index.ts index a9a1b64d..f0499ac5 100644 --- a/src/plugins/skip-silences/index.ts +++ b/src/plugins/skip-silences/index.ts @@ -1,23 +1,20 @@ -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; +import { onRendererLoad, onRendererUnload } from './renderer'; export type SkipSilencesPluginConfig = { enabled: boolean; onlySkipBeginning: boolean; }; -const builder = createPluginBuilder('skip-silences', { +export default createPlugin({ name: 'Skip Silences', restartNeeded: true, config: { enabled: false, onlySkipBeginning: false, } as SkipSilencesPluginConfig, -}); - -export default builder; - -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; + renderer: { + start: onRendererLoad, + stop: onRendererUnload, } -} +}); diff --git a/src/plugins/skip-silences/renderer.ts b/src/plugins/skip-silences/renderer.ts index 3a894906..6e202ae4 100644 --- a/src/plugins/skip-silences/renderer.ts +++ b/src/plugins/skip-silences/renderer.ts @@ -1,140 +1,139 @@ -import builder, { type SkipSilencesPluginConfig } from './index'; +import { RendererContext } from '@/types/contexts'; -export default builder.createRenderer(({ getConfig }) => { - let config: SkipSilencesPluginConfig; +import type { SkipSilencesPluginConfig } from './index'; - let isSilent = false; - let hasAudioStarted = false; +let config: SkipSilencesPluginConfig; - 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[]; +let isSilent = false; +let hasAudioStarted = false; - 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 maxVolume = Number.NEGATIVE_INFINITY; - analyser.getFloatFrequencyData(fftBins); +let playOrSeekHandler: (() => void) | undefined; - for (let i = 4, ii = fftBins.length; i < ii; i++) { - if (fftBins[i] > maxVolume && fftBins[i] < 0) { - maxVolume = fftBins[i]; - } +const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => { + let maxVolume = Number.NEGATIVE_INFINITY; + 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) => { - const video = document.querySelector('video'); - const { audioContext } = e.detail; - const sourceNode = e.detail.audioSource; +const audioCanPlayListener = (e: CustomEvent) => { + const video = document.querySelector('video'); + const { audioContext } = e.detail; + const sourceNode = e.detail.audioSource; - // Use an audio analyser similar to Hark - // https://github.com/otalk/hark/blob/master/hark.bundle.js - const analyser = audioContext.createAnalyser(); - analyser.fftSize = 512; - analyser.smoothingTimeConstant = smoothing; - const fftBins = new Float32Array(analyser.frequencyBinCount); + // Use an audio analyser similar to Hark + // https://github.com/otalk/hark/blob/master/hark.bundle.js + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = smoothing; + const fftBins = new Float32Array(analyser.frequencyBinCount); - sourceNode.connect(analyser); - analyser.connect(audioContext.destination); + sourceNode.connect(analyser); + analyser.connect(audioContext.destination); - const looper = () => { - setTimeout(() => { - const currentVolume = getMaxVolume(analyser, fftBins); + const looper = () => { + setTimeout(() => { + const currentVolume = getMaxVolume(analyser, fftBins); - let history = 0; - if (currentVolume > threshold && isSilent) { - // Trigger quickly, short history - for ( - let i = speakingHistory.length - 3; - i < speakingHistory.length; - 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(); - } + let history = 0; + if (currentVolume > threshold && isSilent) { + // Trigger quickly, short history + for ( + let i = speakingHistory.length - 3; + i < speakingHistory.length; + i++ + ) { + history += speakingHistory[i]; } - speakingHistory.shift(); - speakingHistory.push(Number(currentVolume > threshold)); + if (history >= 2) { + // Not silent + isSilent = false; + hasAudioStarted = true; + } + } else if (currentVolume < threshold && !isSilent) { + for (const element of speakingHistory) { + history += element; + } - looper(); - }, interval); - }; + if (history == 0 // Silent - looper(); - - const skipSilence = () => { - if (config.onlySkipBeginning && hasAudioStarted) { - return; + && !( + video && ( + video.paused + || video.seeking + || video.ended + || video.muted + || video.volume === 0 + ) + ) + ) { + isSilent = true; + skipSilence(); + } } - if (isSilent && video && !video.paused) { - video.currentTime += 0.2; // In s - } - }; + speakingHistory.shift(); + speakingHistory.push(Number(currentVolume > threshold)); - playOrSeekHandler = () => { - hasAudioStarted = false; - skipSilence(); - }; - - video?.addEventListener('play', playOrSeekHandler); - video?.addEventListener('seeked', playOrSeekHandler); + looper(); + }, interval); }; - return { - async onLoad() { - config = await getConfig(); + looper(); - document.addEventListener( - 'audioCanPlay', - audioCanPlayListener, - { - passive: true, - }, - ); - }, - onUnload() { - document.removeEventListener( - 'audioCanPlay', - audioCanPlayListener, - ); + const skipSilence = () => { + if (config.onlySkipBeginning && hasAudioStarted) { + return; + } - if (playOrSeekHandler) { - const video = document.querySelector('video'); - video?.removeEventListener('play', playOrSeekHandler); - video?.removeEventListener('seeked', playOrSeekHandler); - } + if (isSilent && video && !video.paused) { + video.currentTime += 0.2; // In s } }; -}); + + playOrSeekHandler = () => { + hasAudioStarted = false; + skipSilence(); + }; + + video?.addEventListener('play', playOrSeekHandler); + video?.addEventListener('seeked', playOrSeekHandler); +}; + +export const onRendererLoad = async ({ getConfig }: RendererContext) => { + 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); + } +}; diff --git a/src/plugins/sponsorblock/index.ts b/src/plugins/sponsorblock/index.ts index ad735e0a..4df7d7ac 100644 --- a/src/plugins/sponsorblock/index.ts +++ b/src/plugins/sponsorblock/index.ts @@ -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 = { enabled: boolean; @@ -6,7 +13,9 @@ export type SponsorBlockPluginConfig = { categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[]; }; -const builder = createPluginBuilder('sponsorblock', { +let currentSegments: Segment[] = []; + +export default createPlugin({ name: 'SponsorBlock', restartNeeded: true, config: { @@ -21,12 +30,83 @@ const builder = createPluginBuilder('sponsorblock', { 'music_offtopic', ], } 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 { - interface PluginBuilderList { - [builder.id]: typeof builder; + return []; + } + }; + + 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('video'); + if (!video) return; + + video.addEventListener('timeupdate', this.timeUpdateListener); + // Reset segments on song end + video.addEventListener('emptied', this.resetSegments); + }, + stop() { + const video = document.querySelector('video'); + if (!video) return; + + video.removeEventListener('timeupdate', this.timeUpdateListener); + video.removeEventListener('emptied', this.resetSegments); + } } -} +}); diff --git a/src/plugins/sponsorblock/main.ts b/src/plugins/sponsorblock/main.ts deleted file mode 100644 index 46079242..00000000 --- a/src/plugins/sponsorblock/main.ts +++ /dev/null @@ -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); - }); - } -})); diff --git a/src/plugins/sponsorblock/renderer.ts b/src/plugins/sponsorblock/renderer.ts deleted file mode 100644 index 5378597b..00000000 --- a/src/plugins/sponsorblock/renderer.ts +++ /dev/null @@ -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('video'); - if (!video) return; - - video.addEventListener('timeupdate', timeUpdateListener); - // Reset segments on song end - video.addEventListener('emptied', resetSegments); - }, - onUnload() { - const video = document.querySelector('video'); - if (!video) return; - - video.removeEventListener('timeupdate', timeUpdateListener); - video.removeEventListener('emptied', resetSegments); - } - }); -}); diff --git a/src/plugins/taskbar-mediacontrol/index.ts b/src/plugins/taskbar-mediacontrol/index.ts index 5eccbd42..fc4df2ac 100644 --- a/src/plugins/taskbar-mediacontrol/index.ts +++ b/src/plugins/taskbar-mediacontrol/index.ts @@ -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', restartNeeded: true, config: { enabled: false, }, -}); -export default builder; + backend({ window }) { + let currentSongInfo: SongInfo; -declare global { - interface PluginBuilderList { - [builder.id]: typeof builder; + const { playPause, next, previous } = getSongControls(window); + + 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); + }); } -} +}); diff --git a/src/plugins/taskbar-mediacontrol/main.ts b/src/plugins/taskbar-mediacontrol/main.ts deleted file mode 100644 index d6ce30fb..00000000 --- a/src/plugins/taskbar-mediacontrol/main.ts +++ /dev/null @@ -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); - }); - } - }; -}); diff --git a/src/plugins/touchbar/index.ts b/src/plugins/touchbar/index.ts index e5ca2c07..ad158db9 100644 --- a/src/plugins/touchbar/index.ts +++ b/src/plugins/touchbar/index.ts @@ -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', restartNeeded: true, config: { 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 { - interface PluginBuilderList { - [builder.id]: typeof builder; + // 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(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); + }); + }); } -} +}); diff --git a/src/plugins/touchbar/main.ts b/src/plugins/touchbar/main.ts deleted file mode 100644 index de03fa2e..00000000 --- a/src/plugins/touchbar/main.ts +++ /dev/null @@ -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); - }); - }); - } - }; -}); diff --git a/src/plugins/tuna-obs/index.ts b/src/plugins/tuna-obs/index.ts index 84bdb5d8..e979ac49 100644 --- a/src/plugins/tuna-obs/index.ts +++ b/src/plugins/tuna-obs/index.ts @@ -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', restartNeeded: true, config: { 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 { - interface PluginBuilderList { - [builder.id]: typeof builder; + ipc.on('ytmd:player-api-loaded', () => ipc.send('setupTimeChangedListener')); + ipc.on('timeChanged', (t: number) => { + 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); + }); + } } -} +}); diff --git a/src/plugins/tuna-obs/main.ts b/src/plugins/tuna-obs/main.ts deleted file mode 100644 index 542cde9a..00000000 --- a/src/plugins/tuna-obs/main.ts +++ /dev/null @@ -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); - }); - } - }; -}); diff --git a/src/plugins/video-toggle/index.ts b/src/plugins/video-toggle/index.ts index f70e9ade..3a47e3cd 100644 --- a/src/plugins/video-toggle/index.ts +++ b/src/plugins/video-toggle/index.ts @@ -1,7 +1,12 @@ +import buttonTemplate from './templates/button_template.html?raw'; import forceHideStyle from './force-hide.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 = { enabled: boolean; @@ -11,7 +16,7 @@ export type VideoTogglePluginConfig = { align: 'left' | 'middle' | 'right'; } -const builder = createPluginBuilder('video-toggle', { +export default createPlugin({ name: 'Video Toggle', restartNeeded: true, config: { @@ -21,16 +26,273 @@ const builder = createPluginBuilder('video-toggle', { forceHide: false, align: 'left', } as VideoTogglePluginConfig, - styles: [ + stylesheets: [ buttonSwitcherStyle, 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('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('.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('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none'; + document.querySelector('#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('#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('#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('#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; - } -} diff --git a/src/plugins/video-toggle/menu.ts b/src/plugins/video-toggle/menu.ts deleted file mode 100644 index e4722d42..00000000 --- a/src/plugins/video-toggle/menu.ts +++ /dev/null @@ -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 }); - }, - }, - ]; -}); diff --git a/src/plugins/video-toggle/renderer.ts b/src/plugins/video-toggle/renderer.ts deleted file mode 100644 index f5aa4c0d..00000000 --- a/src/plugins/video-toggle/renderer.ts +++ /dev/null @@ -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('video'); - - document.querySelector('#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('.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('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none'; - document.querySelector('#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('#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('#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); - } - }; -}); diff --git a/src/plugins/visualizer/index.ts b/src/plugins/visualizer/index.ts index 8d658b95..e046aa72 100644 --- a/src/plugins/visualizer/index.ts +++ b/src/plugins/visualizer/index.ts @@ -1,6 +1,11 @@ import emptyStyle from './empty-player.css?inline'; - -import { createPluginBuilder } from '../utils/builder'; +import { createPlugin } from '@/utils'; +import { Visualizer } from './visualizers/visualizer'; +import { + ButterchurnVisualizer as butterchurn, + VudioVisualizer as vudio, + WaveVisualizer as wave +} from './visualizers'; type WaveColor = { gradient: string[]; @@ -51,7 +56,7 @@ export type VisualizerPluginConfig = { }; }; -const builder = createPluginBuilder('visualizer', { +export default createPlugin({ name: 'Visualizer', restartNeeded: true, config: { @@ -120,13 +125,97 @@ const builder = createPluginBuilder('visualizer', { ], }, } 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 } = vudio; + + if (config.type === 'wave') { + visualizerType = wave; + } else if (config.type === 'butterchurn') { + visualizerType = butterchurn; + } + + document.addEventListener( + 'audioCanPlay', + (e) => { + const video = document.querySelector('video'); + if (!video) { + return; + } + + const visualizerContainer = document.querySelector('#player'); + if (!visualizerContainer) { + return; + } + + let canvas = document.querySelector('#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; - } -} diff --git a/src/plugins/visualizer/menu.ts b/src/plugins/visualizer/menu.ts deleted file mode 100644 index bae7e654..00000000 --- a/src/plugins/visualizer/menu.ts +++ /dev/null @@ -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 }); - }, - })), - }, - ]; -}); diff --git a/src/plugins/visualizer/renderer.ts b/src/plugins/visualizer/renderer.ts deleted file mode 100644 index b892291e..00000000 --- a/src/plugins/visualizer/renderer.ts +++ /dev/null @@ -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 } = vudio; - - if (config.type === 'wave') { - visualizerType = wave; - } else if (config.type === 'butterchurn') { - visualizerType = butterchurn; - } - - document.addEventListener( - 'audioCanPlay', - (e) => { - const video = document.querySelector('video'); - if (!video) { - return; - } - - const visualizerContainer = document.querySelector('#player'); - if (!visualizerContainer) { - return; - } - - let canvas = document.querySelector('#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 }, - ); - }, - }; -}); diff --git a/src/providers/prompt-options.ts b/src/providers/prompt-options.ts index 80f17f1e..43d592aa 100644 --- a/src/providers/prompt-options.ts +++ b/src/providers/prompt-options.ts @@ -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 = { customStylesheet: 'dark', diff --git a/src/renderer.ts b/src/renderer.ts index e1ea94c2..259f3230 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,6 +1,7 @@ import { startingPages } from './providers/extracted-data'; import setupSongInfo from './providers/song-info-front'; import { + createContext, forceLoadRendererPlugin, forceUnloadRendererPlugin, getAllLoadedRendererPlugins, @@ -74,10 +75,10 @@ function onApiLoaded() { { passive: true }, ); - Object.values(getAllLoadedRendererPlugins()) - .forEach((plugin) => { + Object.entries(getAllLoadedRendererPlugins()) + .forEach(([id, plugin]) => { if (typeof plugin.renderer !== 'function') { - plugin.renderer?.onPlayerApiReady?.(api!); + plugin.renderer?.onPlayerApiReady?.(api!, createContext(id)); } }); @@ -134,7 +135,7 @@ function onApiLoaded() { if (api) { const plugin = getLoadedRendererPlugin(id); if (plugin && typeof plugin.renderer !== 'function') { - plugin.renderer?.onPlayerApiReady?.(api); + plugin.renderer?.onPlayerApiReady?.(api, createContext(id)); } } }, diff --git a/src/tray.ts b/src/tray.ts index 093c120d..22cc7c0a 100644 --- a/src/tray.ts +++ b/src/tray.ts @@ -1,11 +1,11 @@ import { Menu, nativeImage, Tray } from 'electron'; +import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack'; + import { restart } from './providers/app-controls'; import config from './config'; import getSongControls from './providers/song-controls'; -import youtubeMusicTrayIcon from '../assets/youtube-music-tray.png?asset&asarUnpack'; - import type { MenuTemplate } from './menu'; // Prevent tray being garbage collected diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 03c8fb84..ba8e600b 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -18,10 +18,13 @@ export type PluginLifecycleExtra = This & { start?: PluginLifecycleSimple; stop?: PluginLifecycleSimple; onConfigChange?: (this: This, newConfig: Config) => void | Promise; - onPlayerApiReady?: (this: This, playerApi: YoutubePlayer) => void | Promise; }; +export type RendererPluginLifecycleExtra = This & PluginLifecycleExtra & { + onPlayerApiReady?: (this: This, playerApi: YoutubePlayer, context: Context) => void | Promise; +} export type PluginLifecycle = PluginLifecycleSimple | PluginLifecycleExtra; +export type RendererPluginLifecycle = PluginLifecycleSimple | RendererPluginLifecycleExtra; export interface PluginDef< BackendProperties, @@ -46,5 +49,5 @@ export interface PluginDef< } & PluginLifecycle, PreloadProperties>; renderer?: { [Key in keyof RendererProperties]: RendererProperties[Key] - } & PluginLifecycle, RendererProperties>; + } & RendererPluginLifecycle, RendererProperties>; } diff --git a/tsconfig.json b/tsconfig.json index 6a83f316..a42c3609 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "skipLibCheck": true, "paths": { "@/*": ["./src/*"], - "@assets": ["./assets/*"] + "@assets/*": ["./assets/*"] } }, "exclude": ["./dist"],