mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 10:11:46 +00:00
convert plugins
This commit is contained in:
@ -6,7 +6,7 @@ import defaultConfig from './defaults';
|
|||||||
import store from './store';
|
import store from './store';
|
||||||
import plugins from './plugins';
|
import plugins from './plugins';
|
||||||
|
|
||||||
import { restart } from '../providers/app-controls';
|
import { restart } from '@/providers/app-controls';
|
||||||
|
|
||||||
const set = (key: string, value: unknown) => {
|
const set = (key: string, value: unknown) => {
|
||||||
store.set(key, value);
|
store.set(key, value);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { allPlugins } from 'virtual:plugins';
|
|||||||
|
|
||||||
import defaults from './defaults';
|
import defaults from './defaults';
|
||||||
|
|
||||||
import { DefaultPresetList, type Preset } from '../plugins/downloader/types';
|
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
|
||||||
|
|
||||||
const setDefaultPluginOptions = (
|
const setDefaultPluginOptions = (
|
||||||
store: Conf<Record<string, unknown>>,
|
store: Conf<Record<string, unknown>>,
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { startPlugin, stopPlugin } from '@/utils';
|
|||||||
const unregisterStyleMap: Record<string, (() => void)[]> = {};
|
const unregisterStyleMap: Record<string, (() => void)[]> = {};
|
||||||
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
||||||
|
|
||||||
const createContext = <Config extends PluginConfig>(id: string): RendererContext<Config> => ({
|
export const createContext = <Config extends PluginConfig>(id: string): RendererContext<Config> => ({
|
||||||
getConfig: () => window.mainConfig.plugins.getOptions(id),
|
getConfig: () => window.mainConfig.plugins.getOptions(id),
|
||||||
setConfig: async (newConfig) => {
|
setConfig: async (newConfig) => {
|
||||||
await window.ipcRenderer.invoke('set-config', id, newConfig);
|
await window.ipcRenderer.invoke('set-config', id, newConfig);
|
||||||
|
|||||||
@ -9,15 +9,13 @@ import {
|
|||||||
} from 'electron';
|
} from 'electron';
|
||||||
import prompt from 'custom-electron-prompt';
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
|
import { allPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
import { restart } from './providers/app-controls';
|
import { restart } from './providers/app-controls';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
import { startingPages } from './providers/extracted-data';
|
import { startingPages } from './providers/extracted-data';
|
||||||
import promptOptions from './providers/prompt-options';
|
import promptOptions from './providers/prompt-options';
|
||||||
|
|
||||||
/* eslint-disable import/order */
|
|
||||||
import { allPlugins } from 'virtual:plugins';
|
|
||||||
/* eslint-enable import/order */
|
|
||||||
|
|
||||||
import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
|
import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
|
||||||
|
|
||||||
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
|
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { blockers } from './types';
|
import { blockers } from './types';
|
||||||
import { createPlugin } from '@/utils';
|
import { createPlugin } from '@/utils';
|
||||||
import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from '@/plugins/adblocker/blocker';
|
import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from './blocker';
|
||||||
|
|
||||||
import injectCliqzPreload from '@/plugins/adblocker/injectors/inject-cliqz-preload';
|
import injectCliqzPreload from './injectors/inject-cliqz-preload';
|
||||||
import { inject, isInjected } from '@/plugins/adblocker/injectors/inject';
|
import { inject, isInjected } from './injectors/inject';
|
||||||
|
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Howl } from 'howler';
|
|||||||
import promptOptions from '@/providers/prompt-options';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
||||||
import { createPlugin } from '@/utils';
|
import { createPlugin } from '@/utils';
|
||||||
import { VolumeFader } from '@/plugins/crossfade/fader';
|
import { VolumeFader } from './fader';
|
||||||
|
|
||||||
import type { RendererContext } from '@/types/contexts';
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { createPlugin } from '@/utils';
|
import { createPlugin } from '@/utils';
|
||||||
import { onLoad, onUnload } from '@/plugins/discord/main';
|
import { onLoad, onUnload } from './main';
|
||||||
import {onMenu} from "@/plugins/discord/menu";
|
import { onMenu } from './menu';
|
||||||
|
|
||||||
export type DiscordPluginConfig = {
|
export type DiscordPluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { dev } from 'electron-is';
|
|||||||
|
|
||||||
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
|
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
|
||||||
|
|
||||||
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
|
import registerCallback, { type SongInfoCallback, type SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
import type { DiscordPluginConfig } from './index';
|
import type { DiscordPluginConfig } from './index';
|
||||||
|
|
||||||
|
|||||||
@ -3,12 +3,11 @@ import prompt from 'custom-electron-prompt';
|
|||||||
import { clear, connect, isConnected, registerRefresh } from './main';
|
import { clear, connect, isConnected, registerRefresh } from './main';
|
||||||
|
|
||||||
import { singleton } from '@/providers/decorators';
|
import { singleton } from '@/providers/decorators';
|
||||||
|
import promptOptions from '@/providers/prompt-options';
|
||||||
import { setMenuOptions } from '@/config/plugins';
|
import { setMenuOptions } from '@/config/plugins';
|
||||||
import { MenuContext } from '@/types/contexts';
|
import { MenuContext } from '@/types/contexts';
|
||||||
import { DiscordPluginConfig } from '@/plugins/discord/index';
|
import { DiscordPluginConfig } from '@/plugins/discord/index';
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
|
|
||||||
import type { MenuTemplate } from '@/menu';
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { DefaultPresetList, Preset } from './types';
|
|||||||
import style from './style.css?inline';
|
import style from './style.css?inline';
|
||||||
|
|
||||||
import { createPlugin } from '@/utils';
|
import { createPlugin } from '@/utils';
|
||||||
import { onConfigChange, onMainLoad } from '@/plugins/downloader/main';
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
import { onPlayerApiReady, onRendererLoad } from '@/plugins/downloader/renderer';
|
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||||
|
|
||||||
export type DownloaderPluginConfig = {
|
export type DownloaderPluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -15,17 +15,19 @@ export type DownloaderPluginConfig = {
|
|||||||
playlistMaxItems?: number;
|
playlistMaxItems?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const defaultConfig: DownloaderPluginConfig = {
|
||||||
|
enabled: false,
|
||||||
|
downloadFolder: undefined,
|
||||||
|
selectedPreset: 'mp3 (256kbps)', // Selected preset
|
||||||
|
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
|
||||||
|
skipExisting: false,
|
||||||
|
playlistMaxItems: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
export default createPlugin({
|
export default createPlugin({
|
||||||
name: 'Downloader',
|
name: 'Downloader',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: defaultConfig,
|
||||||
enabled: false,
|
|
||||||
downloadFolder: undefined,
|
|
||||||
selectedPreset: 'mp3 (256kbps)', // Selected preset
|
|
||||||
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
|
|
||||||
skipExisting: false,
|
|
||||||
playlistMaxItems: undefined,
|
|
||||||
} as DownloaderPluginConfig,
|
|
||||||
stylesheets: [style],
|
stylesheets: [style],
|
||||||
backend: {
|
backend: {
|
||||||
start: onMainLoad,
|
start: onMainLoad,
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import { BackendContext } from '@/types/contexts';
|
|||||||
|
|
||||||
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
|
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
|
||||||
|
|
||||||
import { DownloaderPluginConfig } from '../index';
|
import { defaultConfig, type DownloaderPluginConfig } from '../index';
|
||||||
|
|
||||||
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||||
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
||||||
@ -90,7 +90,7 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
|
|||||||
.join(';');
|
.join(';');
|
||||||
};
|
};
|
||||||
|
|
||||||
let config: DownloaderPluginConfig;
|
let config: DownloaderPluginConfig = defaultConfig;
|
||||||
|
|
||||||
export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext<DownloaderPluginConfig>) => {
|
export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext<DownloaderPluginConfig>) => {
|
||||||
win = _win;
|
win = _win;
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import titlebarStyle from './titlebar.css?inline';
|
import titlebarStyle from './titlebar.css?inline';
|
||||||
import { createPlugin } from '@/utils';
|
import { createPlugin } from '@/utils';
|
||||||
import { onMainLoad } from '@/plugins/in-app-menu/main';
|
import { onMainLoad } from './main';
|
||||||
import { onMenu } from '@/plugins/in-app-menu/menu';
|
import { onMenu } from './menu';
|
||||||
import { onPlayerApiReady, onRendererLoad } from '@/plugins/in-app-menu/renderer';
|
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||||
|
|
||||||
export interface InAppMenuConfig {
|
export interface InAppMenuConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { createPlugin } from '@/utils';
|
||||||
|
import registerCallback from '@/providers/song-info';
|
||||||
|
import { addScrobble, getAndSetSessionKey, setNowPlaying } from './main';
|
||||||
|
|
||||||
export interface LastFmPluginConfig {
|
export interface LastFmPluginConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -30,7 +32,7 @@ export interface LastFmPluginConfig {
|
|||||||
secret: string;
|
secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = createPluginBuilder('last-fm', {
|
export default createPlugin({
|
||||||
name: 'Last.fm',
|
name: 'Last.fm',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -39,12 +41,34 @@ const builder = createPluginBuilder('last-fm', {
|
|||||||
api_key: '04d76faaac8726e60988e14c105d421a',
|
api_key: '04d76faaac8726e60988e14c105d421a',
|
||||||
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
||||||
} as LastFmPluginConfig,
|
} as LastFmPluginConfig,
|
||||||
});
|
async backend({ getConfig, setConfig }) {
|
||||||
|
let config = await getConfig();
|
||||||
|
// This will store the timeout that will trigger addScrobble
|
||||||
|
let scrobbleTimer: number | undefined;
|
||||||
|
|
||||||
export default builder;
|
if (!config.api_root) {
|
||||||
|
config.enabled = true;
|
||||||
|
setConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
if (!config.session_key) {
|
||||||
interface PluginBuilderList {
|
// Not authenticated
|
||||||
[builder.id]: typeof builder;
|
config = await getAndSetSessionKey(config, setConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
// Set remove the old scrobble timer
|
||||||
|
clearTimeout(scrobbleTimer);
|
||||||
|
if (!songInfo.isPaused) {
|
||||||
|
setNowPlaying(songInfo, config, setConfig);
|
||||||
|
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
||||||
|
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
||||||
|
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
||||||
|
// Scrobble still needs to happen
|
||||||
|
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
||||||
|
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -2,10 +2,8 @@ import crypto from 'node:crypto';
|
|||||||
|
|
||||||
import { net, shell } from 'electron';
|
import { net, shell } from 'electron';
|
||||||
|
|
||||||
import builder, { type LastFmPluginConfig } from './index';
|
import type { LastFmPluginConfig } from './index';
|
||||||
|
import type { SongInfo } from '@/providers/song-info';
|
||||||
import { setOptions } from '../../config/plugins';
|
|
||||||
import registerCallback, { type SongInfo } from '../../providers/song-info';
|
|
||||||
|
|
||||||
interface LastFmData {
|
interface LastFmData {
|
||||||
method: string,
|
method: string,
|
||||||
@ -53,7 +51,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
|||||||
keys.sort();
|
keys.sort();
|
||||||
let sig = '';
|
let sig = '';
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (String(key) === 'format') {
|
if (key === 'format') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +81,9 @@ const authenticate = async (config: LastFmPluginConfig) => {
|
|||||||
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAndSetSessionKey = async (config: LastFmPluginConfig) => {
|
type SetConfType = (conf: Partial<Omit<LastFmPluginConfig, 'enabled'>>) => (void | Promise<void>);
|
||||||
|
|
||||||
|
export const getAndSetSessionKey = async (config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||||
// Get and store the session key
|
// Get and store the session key
|
||||||
const data = {
|
const data = {
|
||||||
api_key: config.api_key,
|
api_key: config.api_key,
|
||||||
@ -102,19 +102,19 @@ const getAndSetSessionKey = async (config: LastFmPluginConfig) => {
|
|||||||
if (json.error) {
|
if (json.error) {
|
||||||
config.token = await createToken(config);
|
config.token = await createToken(config);
|
||||||
await authenticate(config);
|
await authenticate(config);
|
||||||
setOptions('last-fm', config);
|
setConfig(config);
|
||||||
}
|
}
|
||||||
if (json.session) {
|
if (json.session) {
|
||||||
config.session_key = json.session.key;
|
config.session_key = json.session.key;
|
||||||
}
|
}
|
||||||
setOptions('last-fm', config);
|
setConfig(config);
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData) => {
|
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData, setConfig: SetConfType) => {
|
||||||
// This sends a post request to the api, and adds the common data
|
// This sends a post request to the api, and adds the common data
|
||||||
if (!config.session_key) {
|
if (!config.session_key) {
|
||||||
await getAndSetSessionKey(config);
|
await getAndSetSessionKey(config, setConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
const postData: LastFmSongData = {
|
const postData: LastFmSongData = {
|
||||||
@ -143,58 +143,24 @@ const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig,
|
|||||||
config.session_key = undefined;
|
config.session_key = undefined;
|
||||||
config.token = await createToken(config);
|
config.token = await createToken(config);
|
||||||
await authenticate(config);
|
await authenticate(config);
|
||||||
setOptions('last-fm', config);
|
setConfig(config);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig) => {
|
export const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||||
// This adds one scrobbled song to last.fm
|
// This adds one scrobbled song to last.fm
|
||||||
const data = {
|
const data = {
|
||||||
method: 'track.scrobble',
|
method: 'track.scrobble',
|
||||||
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
|
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
|
||||||
};
|
};
|
||||||
postSongDataToAPI(songInfo, config, data);
|
postSongDataToAPI(songInfo, config, data, setConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig) => {
|
export const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||||
// This sets the now playing status in last.fm
|
// This sets the now playing status in last.fm
|
||||||
const data = {
|
const data = {
|
||||||
method: 'track.updateNowPlaying',
|
method: 'track.updateNowPlaying',
|
||||||
};
|
};
|
||||||
postSongDataToAPI(songInfo, config, data);
|
postSongDataToAPI(songInfo, config, data, setConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
// This will store the timeout that will trigger addScrobble
|
|
||||||
let scrobbleTimer: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
export default builder.createMain(({ getConfig, send }) => ({
|
|
||||||
async onLoad(_win) {
|
|
||||||
let config = await getConfig();
|
|
||||||
|
|
||||||
if (!config.api_root) {
|
|
||||||
config.enabled = true;
|
|
||||||
setOptions('last-fm', config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.session_key) {
|
|
||||||
// Not authenticated
|
|
||||||
config = await getAndSetSessionKey(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
// Set remove the old scrobble timer
|
|
||||||
clearTimeout(scrobbleTimer);
|
|
||||||
if (!songInfo.isPaused) {
|
|
||||||
setNowPlaying(songInfo, config);
|
|
||||||
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
|
||||||
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
|
||||||
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
|
||||||
// Scrobble still needs to happen
|
|
||||||
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
|
||||||
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|||||||
@ -1,17 +1,88 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { net } from 'electron';
|
||||||
|
|
||||||
const builder = createPluginBuilder('lumiastream', {
|
import { createPlugin } from '@/utils';
|
||||||
|
import registerCallback from '@/providers/song-info';
|
||||||
|
|
||||||
|
type LumiaData = {
|
||||||
|
origin: string;
|
||||||
|
eventType: string;
|
||||||
|
url?: string;
|
||||||
|
videoId?: string;
|
||||||
|
playlistId?: string;
|
||||||
|
cover?: string|null;
|
||||||
|
cover_url?: string|null;
|
||||||
|
title?: string;
|
||||||
|
artists?: string[];
|
||||||
|
status?: string;
|
||||||
|
progress?: number;
|
||||||
|
duration?: number;
|
||||||
|
album_url?: string|null;
|
||||||
|
album?: string|null;
|
||||||
|
views?: number;
|
||||||
|
isPaused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
name: 'Lumia Stream [beta]',
|
name: 'Lumia Stream [beta]',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
});
|
backend() {
|
||||||
|
const secToMilisec = (t?: number) => t ? Math.round(Number(t) * 1e3) : undefined;
|
||||||
|
const previousStatePaused = null;
|
||||||
|
|
||||||
export default builder;
|
const data: LumiaData = {
|
||||||
|
origin: 'youtubemusic',
|
||||||
|
eventType: 'switchSong',
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
const post = (data: LumiaData) => {
|
||||||
interface PluginBuilderList {
|
const port = 39231;
|
||||||
[builder.id]: typeof builder;
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Access-Control-Allow-Headers': '*',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
} as const;
|
||||||
|
const url = `http://127.0.0.1:${port}/api/media`;
|
||||||
|
|
||||||
|
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
|
||||||
|
.catch((error: { code: number, errno: number }) => {
|
||||||
|
console.log(
|
||||||
|
`Error: '${
|
||||||
|
error.code || error.errno
|
||||||
|
}' - when trying to access lumiastream webserver at port ${port}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
if (!songInfo.title && !songInfo.artist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousStatePaused === null) {
|
||||||
|
data.eventType = 'switchSong';
|
||||||
|
} else if (previousStatePaused !== songInfo.isPaused) {
|
||||||
|
data.eventType = 'playPause';
|
||||||
|
}
|
||||||
|
|
||||||
|
data.duration = secToMilisec(songInfo.songDuration);
|
||||||
|
data.progress = secToMilisec(songInfo.elapsedSeconds);
|
||||||
|
data.url = songInfo.url;
|
||||||
|
data.videoId = songInfo.videoId;
|
||||||
|
data.playlistId = songInfo.playlistId;
|
||||||
|
data.cover = songInfo.imageSrc;
|
||||||
|
data.cover_url = songInfo.imageSrc;
|
||||||
|
data.album_url = songInfo.imageSrc;
|
||||||
|
data.title = songInfo.title;
|
||||||
|
data.artists = [songInfo.artist];
|
||||||
|
data.status = songInfo.isPaused ? 'stopped' : 'playing';
|
||||||
|
data.isPaused = songInfo.isPaused;
|
||||||
|
data.album = songInfo.album;
|
||||||
|
data.views = songInfo.views;
|
||||||
|
post(data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,26 +1,41 @@
|
|||||||
import style from './style.css?inline';
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
import { createPluginBuilder } from '../utils/builder';
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
|
import { onRendererLoad } from './renderer';
|
||||||
|
|
||||||
export type LyricsGeniusPluginConfig = {
|
export type LyricsGeniusPluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
romanizedLyrics: boolean;
|
romanizedLyrics: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = createPluginBuilder('lyrics-genius', {
|
export default createPlugin({
|
||||||
name: 'Lyrics Genius',
|
name: 'Lyrics Genius',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
romanizedLyrics: false,
|
romanizedLyrics: false,
|
||||||
} as LyricsGeniusPluginConfig,
|
} as LyricsGeniusPluginConfig,
|
||||||
styles: [style],
|
stylesheets: [style],
|
||||||
|
async menu({ getConfig, setConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Romanized Lyrics',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.romanizedLyrics,
|
||||||
|
click(item) {
|
||||||
|
setConfig({
|
||||||
|
romanizedLyrics: item.checked,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
backend: {
|
||||||
|
start: onMainLoad,
|
||||||
|
onConfigChange,
|
||||||
|
},
|
||||||
|
renderer: onRendererLoad,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default builder;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,33 +3,31 @@ import is from 'electron-is';
|
|||||||
import { convert } from 'html-to-text';
|
import { convert } from 'html-to-text';
|
||||||
|
|
||||||
import { GetGeniusLyric } from './types';
|
import { GetGeniusLyric } from './types';
|
||||||
|
import { cleanupName, type SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
import builder from './index';
|
import type { LyricsGeniusPluginConfig } from './index';
|
||||||
|
|
||||||
import { cleanupName, type SongInfo } from '../../providers/song-info';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
|
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
|
||||||
let revRomanized = false;
|
let revRomanized = false;
|
||||||
|
|
||||||
export default builder.createMain(({ handle, getConfig }) =>{
|
export const onMainLoad = async ({ ipc, getConfig }: BackendContext<LyricsGeniusPluginConfig>) => {
|
||||||
return {
|
const config = await getConfig();
|
||||||
async onLoad() {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
if (config.romanizedLyrics) {
|
if (config.romanizedLyrics) {
|
||||||
revRomanized = true;
|
revRomanized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => {
|
ipc.handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => {
|
||||||
const metadata = extractedSongInfo;
|
const metadata = extractedSongInfo;
|
||||||
return await fetchFromGenius(metadata);
|
return await fetchFromGenius(metadata);
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
onConfigChange(newConfig) {
|
|
||||||
revRomanized = newConfig.romanizedLyrics;
|
export const onConfigChange = (newConfig: LyricsGeniusPluginConfig) => {
|
||||||
}
|
revRomanized = newConfig.romanizedLyrics;
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchFromGenius = async (metadata: SongInfo) => {
|
export const fetchFromGenius = async (metadata: SongInfo) => {
|
||||||
const songTitle = `${cleanupName(metadata.title)}`;
|
const songTitle = `${cleanupName(metadata.title)}`;
|
||||||
|
|||||||
@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import builder from './index';
|
import type { SongInfo } from '@/providers/song-info';
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
import type { LyricsGeniusPluginConfig } from '@/plugins/lyrics-genius/index';
|
||||||
|
|
||||||
import type { SongInfo } from '../../providers/song-info';
|
export const onRendererLoad = ({ ipc: { invoke, on } }: RendererContext<LyricsGeniusPluginConfig>) => {
|
||||||
|
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
|
||||||
export default builder.createRenderer(({ on, invoke }) => ({
|
lyricsContainer.innerHTML = `
|
||||||
onLoad() {
|
|
||||||
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
|
|
||||||
lyricsContainer.innerHTML = `
|
|
||||||
<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
|
<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
|
||||||
${lyrics?.replaceAll(/\r\n|\r|\n/g, '<br/>') ?? 'Could not retrieve lyrics from genius'}
|
${lyrics?.replaceAll(/\r\n|\r|\n/g, '<br/>') ?? 'Could not retrieve lyrics from genius'}
|
||||||
</div>
|
</div>
|
||||||
@ -13,97 +12,96 @@ export default builder.createRenderer(({ on, invoke }) => ({
|
|||||||
</yt-formatted-string>
|
</yt-formatted-string>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (lyrics) {
|
if (lyrics) {
|
||||||
const footer = lyricsContainer.querySelector('.footer');
|
const footer = lyricsContainer.querySelector('.footer');
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
footer.textContent = 'Source: Genius';
|
footer.textContent = 'Source: Genius';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let unregister: (() => void) | null = null;
|
let unregister: (() => void) | null = null;
|
||||||
|
|
||||||
on('update-song-info', (extractedSongInfo: SongInfo) => {
|
on('update-song-info', (extractedSongInfo: SongInfo) => {
|
||||||
unregister?.();
|
unregister?.();
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const tabList = document.querySelectorAll<HTMLElement>('tp-yt-paper-tab');
|
const tabList = document.querySelectorAll<HTMLElement>('tp-yt-paper-tab');
|
||||||
const tabs = {
|
const tabs = {
|
||||||
upNext: tabList[0],
|
upNext: tabList[0],
|
||||||
lyrics: tabList[1],
|
lyrics: tabList[1],
|
||||||
discover: tabList[2],
|
discover: tabList[2],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if disabled
|
// Check if disabled
|
||||||
if (!tabs.lyrics?.hasAttribute('disabled')) return;
|
if (!tabs.lyrics?.hasAttribute('disabled')) return;
|
||||||
|
|
||||||
const lyrics = await invoke<string | null>(
|
const lyrics = await invoke(
|
||||||
'search-genius-lyrics',
|
'search-genius-lyrics',
|
||||||
extractedSongInfo,
|
extractedSongInfo,
|
||||||
|
) as string | null;
|
||||||
|
|
||||||
|
if (!lyrics) {
|
||||||
|
// Delete previous lyrics if tab is open and couldn't get new lyrics
|
||||||
|
tabs.upNext.click();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.electronIs.dev()) {
|
||||||
|
console.log('Fetched lyrics from Genius');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryToInjectLyric = (callback?: () => void) => {
|
||||||
|
const lyricsContainer = document.querySelector(
|
||||||
|
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!lyrics) {
|
if (lyricsContainer) {
|
||||||
// Delete previous lyrics if tab is open and couldn't get new lyrics
|
callback?.();
|
||||||
tabs.upNext.click();
|
|
||||||
|
|
||||||
return;
|
setLyrics(lyricsContainer, lyrics);
|
||||||
|
applyLyricsTabState();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
if (window.electronIs.dev()) {
|
const applyLyricsTabState = () => {
|
||||||
console.log('Fetched lyrics from Genius');
|
if (lyrics) {
|
||||||
|
tabs.lyrics.removeAttribute('disabled');
|
||||||
|
tabs.lyrics.removeAttribute('aria-disabled');
|
||||||
|
} else {
|
||||||
|
tabs.lyrics.setAttribute('disabled', '');
|
||||||
|
tabs.lyrics.setAttribute('aria-disabled', '');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const lyricsTabHandler = () => {
|
||||||
|
const tabContainer = document.querySelector('ytmusic-tab-renderer');
|
||||||
|
if (!tabContainer) return;
|
||||||
|
|
||||||
const tryToInjectLyric = (callback?: () => void) => {
|
const observer = new MutationObserver((_, observer) => {
|
||||||
const lyricsContainer = document.querySelector(
|
tryToInjectLyric(() => observer.disconnect());
|
||||||
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (lyricsContainer) {
|
observer.observe(tabContainer, {
|
||||||
callback?.();
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
setLyrics(lyricsContainer, lyrics);
|
applyLyricsTabState();
|
||||||
applyLyricsTabState();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const applyLyricsTabState = () => {
|
|
||||||
if (lyrics) {
|
|
||||||
tabs.lyrics.removeAttribute('disabled');
|
|
||||||
tabs.lyrics.removeAttribute('aria-disabled');
|
|
||||||
} else {
|
|
||||||
tabs.lyrics.setAttribute('disabled', '');
|
|
||||||
tabs.lyrics.setAttribute('aria-disabled', '');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const lyricsTabHandler = () => {
|
|
||||||
const tabContainer = document.querySelector('ytmusic-tab-renderer');
|
|
||||||
if (!tabContainer) return;
|
|
||||||
|
|
||||||
const observer = new MutationObserver((_, observer) => {
|
tabs.discover.addEventListener('click', applyLyricsTabState);
|
||||||
tryToInjectLyric(() => observer.disconnect());
|
tabs.lyrics.addEventListener('click', lyricsTabHandler);
|
||||||
});
|
tabs.upNext.addEventListener('click', applyLyricsTabState);
|
||||||
|
|
||||||
observer.observe(tabContainer, {
|
tryToInjectLyric();
|
||||||
attributes: true,
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
applyLyricsTabState();
|
unregister = () => {
|
||||||
|
tabs.discover.removeEventListener('click', applyLyricsTabState);
|
||||||
tabs.discover.addEventListener('click', applyLyricsTabState);
|
tabs.lyrics.removeEventListener('click', lyricsTabHandler);
|
||||||
tabs.lyrics.addEventListener('click', lyricsTabHandler);
|
tabs.upNext.removeEventListener('click', applyLyricsTabState);
|
||||||
tabs.upNext.addEventListener('click', applyLyricsTabState);
|
};
|
||||||
|
}, 500);
|
||||||
tryToInjectLyric();
|
});
|
||||||
|
};
|
||||||
unregister = () => {
|
|
||||||
tabs.discover.removeEventListener('click', applyLyricsTabState);
|
|
||||||
tabs.lyrics.removeEventListener('click', lyricsTabHandler);
|
|
||||||
tabs.upNext.removeEventListener('click', applyLyricsTabState);
|
|
||||||
};
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|||||||
@ -1,20 +1,24 @@
|
|||||||
import style from './style.css?inline';
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||||
|
|
||||||
import { createPluginBuilder } from '../utils/builder';
|
import forwardHTML from './templates/forward.html?raw';
|
||||||
|
import backHTML from './templates/back.html?raw';
|
||||||
|
|
||||||
const builder = createPluginBuilder('navigation', {
|
export default createPlugin({
|
||||||
name: 'Navigation',
|
name: 'Navigation',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
styles: [style],
|
stylesheets: [style],
|
||||||
|
renderer() {
|
||||||
|
const forwardButton = ElementFromHtml(forwardHTML);
|
||||||
|
const backButton = ElementFromHtml(backHTML);
|
||||||
|
const menu = document.querySelector('#right-content');
|
||||||
|
|
||||||
|
if (menu) {
|
||||||
|
menu.prepend(backButton, forwardButton);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default builder;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,20 +1,46 @@
|
|||||||
import style from './style.css?inline';
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
import { createPluginBuilder } from '../utils/builder';
|
export default createPlugin({
|
||||||
|
|
||||||
const builder = createPluginBuilder('no-google-login', {
|
|
||||||
name: 'Remove Google Login',
|
name: 'Remove Google Login',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
styles: [style],
|
stylesheets: [style],
|
||||||
});
|
renderer() {
|
||||||
|
const elementsToRemove = [
|
||||||
|
'.sign-in-link.ytmusic-nav-bar',
|
||||||
|
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
|
||||||
|
];
|
||||||
|
|
||||||
export default builder;
|
for (const selector of elementsToRemove) {
|
||||||
|
const node = document.querySelector(selector);
|
||||||
|
if (node) {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
// Remove the library button
|
||||||
interface PluginBuilderList {
|
const libraryIconPath
|
||||||
[builder.id]: typeof builder;
|
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const menuEntries = document.querySelectorAll(
|
||||||
|
'#items ytmusic-guide-entry-renderer',
|
||||||
|
);
|
||||||
|
menuEntries.forEach((item) => {
|
||||||
|
const icon = item.querySelector('path');
|
||||||
|
if (icon) {
|
||||||
|
observer.disconnect();
|
||||||
|
if (icon.getAttribute('d') === libraryIconPath) {
|
||||||
|
item.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
};
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
@ -1,36 +1,46 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
|
||||||
export interface NotificationsPluginConfig {
|
export interface NotificationsPluginConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
unpauseNotification: boolean;
|
unpauseNotification: boolean;
|
||||||
|
/**
|
||||||
|
* Has effect only on Linux
|
||||||
|
*/
|
||||||
urgency: 'low' | 'normal' | 'critical';
|
urgency: 'low' | 'normal' | 'critical';
|
||||||
|
/**
|
||||||
|
* the following has effect only on Windows
|
||||||
|
*/
|
||||||
interactive: boolean;
|
interactive: boolean;
|
||||||
|
/**
|
||||||
|
* See plugins/notifications/utils for more info
|
||||||
|
*/
|
||||||
toastStyle: number;
|
toastStyle: number;
|
||||||
refreshOnPlayPause: boolean;
|
refreshOnPlayPause: boolean;
|
||||||
trayControls: boolean;
|
trayControls: boolean;
|
||||||
hideButtonText: boolean;
|
hideButtonText: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = createPluginBuilder('notifications', {
|
export const defaultConfig: NotificationsPluginConfig = {
|
||||||
|
enabled: false,
|
||||||
|
unpauseNotification: false,
|
||||||
|
urgency: 'normal',
|
||||||
|
interactive: true,
|
||||||
|
toastStyle: 1,
|
||||||
|
refreshOnPlayPause: false,
|
||||||
|
trayControls: true,
|
||||||
|
hideButtonText: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
name: 'Notifications',
|
name: 'Notifications',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: defaultConfig,
|
||||||
enabled: false,
|
menu: onMenu,
|
||||||
unpauseNotification: false,
|
backend: {
|
||||||
urgency: 'normal', // Has effect only on Linux
|
start: onMainLoad,
|
||||||
// the following has effect only on Windows
|
onConfigChange,
|
||||||
interactive: true,
|
},
|
||||||
toastStyle: 1, // See plugins/notifications/utils for more info
|
|
||||||
refreshOnPlayPause: false,
|
|
||||||
trayControls: true,
|
|
||||||
hideButtonText: false,
|
|
||||||
} as NotificationsPluginConfig,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default builder;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,21 +1,20 @@
|
|||||||
import { app, BrowserWindow, Notification } from 'electron';
|
import { app, BrowserWindow, Notification } from 'electron';
|
||||||
|
|
||||||
|
import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
|
||||||
|
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
|
||||||
|
import nextIcon from '@assets/media-icons-black/next.png?asset&asarUnpack';
|
||||||
|
import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpack';
|
||||||
|
|
||||||
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
|
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
|
||||||
|
|
||||||
import getSongControls from '../../providers/song-controls';
|
import getSongControls from '@/providers/song-controls';
|
||||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
import registerCallback, { SongInfo } from '@/providers/song-info';
|
||||||
import { changeProtocolHandler } from '../../providers/protocol-handler';
|
import { changeProtocolHandler } from '@/providers/protocol-handler';
|
||||||
import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray';
|
import { setTrayOnClick, setTrayOnDoubleClick } from '@/tray';
|
||||||
import { mediaIcons } from '../../types/media-icons';
|
import { mediaIcons } from '@/types/media-icons';
|
||||||
|
|
||||||
import playIcon from '../../../assets/media-icons-black/play.png?asset&asarUnpack';
|
|
||||||
import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnpack';
|
|
||||||
import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack';
|
|
||||||
import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack';
|
|
||||||
|
|
||||||
import { MainPluginContext } from '../utils/builder';
|
|
||||||
|
|
||||||
import type { NotificationsPluginConfig } from './index';
|
import type { NotificationsPluginConfig } from './index';
|
||||||
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
let songControls: ReturnType<typeof getSongControls>;
|
let songControls: ReturnType<typeof getSongControls>;
|
||||||
let savedNotification: Notification | undefined;
|
let savedNotification: Notification | undefined;
|
||||||
@ -25,7 +24,7 @@ type Accessor<T> = () => T;
|
|||||||
export default (
|
export default (
|
||||||
win: BrowserWindow,
|
win: BrowserWindow,
|
||||||
config: Accessor<NotificationsPluginConfig>,
|
config: Accessor<NotificationsPluginConfig>,
|
||||||
{ on, send }: MainPluginContext<NotificationsPluginConfig>,
|
{ ipc: { on, send } }: BackendContext<NotificationsPluginConfig>,
|
||||||
) => {
|
) => {
|
||||||
const sendNotification = (songInfo: SongInfo) => {
|
const sendNotification = (songInfo: SongInfo) => {
|
||||||
const iconSrc = notificationImage(songInfo, config());
|
const iconSrc = notificationImage(songInfo, config());
|
||||||
|
|||||||
@ -5,11 +5,12 @@ import is from 'electron-is';
|
|||||||
import { notificationImage } from './utils';
|
import { notificationImage } from './utils';
|
||||||
import interactive from './interactive';
|
import interactive from './interactive';
|
||||||
|
|
||||||
import builder, { NotificationsPluginConfig } from './index';
|
import { defaultConfig, type NotificationsPluginConfig } from './index';
|
||||||
|
import registerCallback, { type SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
let config: NotificationsPluginConfig = builder.config;
|
let config: NotificationsPluginConfig = defaultConfig;
|
||||||
|
|
||||||
const notify = (info: SongInfo) => {
|
const notify = (info: SongInfo) => {
|
||||||
// Send the notification
|
// Send the notification
|
||||||
@ -42,17 +43,14 @@ const setup = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default builder.createMain((context) => {
|
export const onMainLoad = async (context: BackendContext<NotificationsPluginConfig>) => {
|
||||||
return {
|
config = await context.getConfig();
|
||||||
async onLoad(win) {
|
|
||||||
config = await context.getConfig();
|
|
||||||
|
|
||||||
// Register the callback for new song information
|
// Register the callback for new song information
|
||||||
if (is.windows() && config.interactive) interactive(win, () => config, context);
|
if (is.windows() && config.interactive) interactive(context.window, () => config, context);
|
||||||
else setup();
|
else setup();
|
||||||
},
|
};
|
||||||
onConfigChange(newConfig) {
|
|
||||||
config = newConfig;
|
export const onConfigChange = (newConfig: NotificationsPluginConfig) => {
|
||||||
}
|
config = newConfig;
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
|
|
||||||
import { MenuItem } from 'electron';
|
import { MenuItem } from 'electron';
|
||||||
|
|
||||||
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
|
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
|
||||||
|
|
||||||
import builder, { NotificationsPluginConfig } from './index';
|
import type { NotificationsPluginConfig } from './index';
|
||||||
|
|
||||||
import type { MenuTemplate } from '../../menu';
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
|
||||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
export const onMenu = async ({ getConfig, setConfig }: MenuContext<NotificationsPluginConfig>): Promise<MenuTemplate> => {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
const getToastStyleMenuItems = (options: NotificationsPluginConfig) => {
|
const getToastStyleMenuItems = (options: NotificationsPluginConfig) => {
|
||||||
@ -25,7 +25,7 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return array as Electron.MenuItemConstructorOptions[];
|
return array as Electron.MenuItemConstructorOptions[];
|
||||||
}
|
};
|
||||||
|
|
||||||
const getMenu = (): MenuTemplate => {
|
const getMenu = (): MenuTemplate => {
|
||||||
if (is.linux()) {
|
if (is.linux()) {
|
||||||
@ -92,4 +92,4 @@ export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
|||||||
click: (item) => setConfig({ unpauseNotification: item.checked }),
|
click: (item) => setConfig({ unpauseNotification: item.checked }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
};
|
||||||
|
|||||||
@ -3,12 +3,12 @@ import fs from 'node:fs';
|
|||||||
|
|
||||||
import { app, NativeImage } from 'electron';
|
import { app, NativeImage } from 'electron';
|
||||||
|
|
||||||
import { cache } from '../../providers/decorators';
|
import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack';
|
||||||
import { SongInfo } from '../../providers/song-info';
|
|
||||||
|
|
||||||
import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack';
|
import { cache } from '@/providers/decorators';
|
||||||
import {NotificationsPluginConfig} from "./index";
|
import { SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
|
import type { NotificationsPluginConfig } from './index';
|
||||||
|
|
||||||
const userData = app.getPath('userData');
|
const userData = app.getPath('userData');
|
||||||
const temporaryIcon = path.join(userData, 'tempIcon.png');
|
const temporaryIcon = path.join(userData, 'tempIcon.png');
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import style from './style.css?inline';
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
import { createPluginBuilder } from '../utils/builder';
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||||
|
|
||||||
export type PictureInPicturePluginConfig = {
|
export type PictureInPicturePluginConfig = {
|
||||||
'enabled': boolean;
|
'enabled': boolean;
|
||||||
@ -14,7 +17,7 @@ export type PictureInPicturePluginConfig = {
|
|||||||
'useNativePiP': boolean;
|
'useNativePiP': boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = createPluginBuilder('picture-in-picture', {
|
export default createPlugin({
|
||||||
name: 'Picture In Picture',
|
name: 'Picture In Picture',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -28,13 +31,15 @@ const builder = createPluginBuilder('picture-in-picture', {
|
|||||||
'isInPiP': false,
|
'isInPiP': false,
|
||||||
'useNativePiP': true,
|
'useNativePiP': true,
|
||||||
} as PictureInPicturePluginConfig,
|
} as PictureInPicturePluginConfig,
|
||||||
styles: [style],
|
stylesheets: [style],
|
||||||
});
|
menu: onMenu,
|
||||||
|
|
||||||
export default builder;
|
backend: {
|
||||||
|
start: onMainLoad,
|
||||||
declare global {
|
onConfigChange,
|
||||||
interface PluginBuilderList {
|
},
|
||||||
[builder.id]: typeof builder;
|
renderer: {
|
||||||
|
start: onRendererLoad,
|
||||||
|
onPlayerApiReady,
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -1,22 +1,18 @@
|
|||||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
import { app } from 'electron';
|
||||||
|
|
||||||
import style from './style.css?inline';
|
import type { PictureInPicturePluginConfig } from './index';
|
||||||
|
|
||||||
import builder, { PictureInPicturePluginConfig } from './index';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
import { injectCSS } from '../utils/main';
|
let config: PictureInPicturePluginConfig;
|
||||||
|
|
||||||
export default builder.createMain(({ getConfig, setConfig, send, handle, on }) => {
|
export const onMainLoad = async ({ window, getConfig, setConfig, ipc: { send, handle, on } }: BackendContext<PictureInPicturePluginConfig>) => {
|
||||||
let isInPiP = false;
|
let isInPiP = false;
|
||||||
let originalPosition: number[];
|
let originalPosition: number[];
|
||||||
let originalSize: number[];
|
let originalSize: number[];
|
||||||
let originalFullScreen: boolean;
|
let originalFullScreen: boolean;
|
||||||
let originalMaximized: boolean;
|
let originalMaximized: boolean;
|
||||||
|
|
||||||
let win: BrowserWindow;
|
|
||||||
|
|
||||||
let config: PictureInPicturePluginConfig;
|
|
||||||
|
|
||||||
const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10];
|
const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10];
|
||||||
const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275];
|
const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275];
|
||||||
|
|
||||||
@ -25,59 +21,59 @@ export default builder.createMain(({ getConfig, setConfig, send, handle, on }) =
|
|||||||
setConfig({ isInPiP });
|
setConfig({ isInPiP });
|
||||||
|
|
||||||
if (isInPiP) {
|
if (isInPiP) {
|
||||||
originalFullScreen = win.isFullScreen();
|
originalFullScreen = window.isFullScreen();
|
||||||
if (originalFullScreen) {
|
if (originalFullScreen) {
|
||||||
win.setFullScreen(false);
|
window.setFullScreen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
originalMaximized = win.isMaximized();
|
originalMaximized = window.isMaximized();
|
||||||
if (originalMaximized) {
|
if (originalMaximized) {
|
||||||
win.unmaximize();
|
window.unmaximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
originalPosition = win.getPosition();
|
originalPosition = window.getPosition();
|
||||||
originalSize = win.getSize();
|
originalSize = window.getSize();
|
||||||
|
|
||||||
handle('before-input-event', blockShortcutsInPiP);
|
handle('before-input-event', blockShortcutsInPiP);
|
||||||
|
|
||||||
win.setMaximizable(false);
|
window.setMaximizable(false);
|
||||||
win.setFullScreenable(false);
|
window.setFullScreenable(false);
|
||||||
|
|
||||||
send('pip-toggle', true);
|
send('pip-toggle', true);
|
||||||
|
|
||||||
app.dock?.hide();
|
app.dock?.hide();
|
||||||
win.setVisibleOnAllWorkspaces(true, {
|
window.setVisibleOnAllWorkspaces(true, {
|
||||||
visibleOnFullScreen: true,
|
visibleOnFullScreen: true,
|
||||||
});
|
});
|
||||||
app.dock?.show();
|
app.dock?.show();
|
||||||
if (config.alwaysOnTop) {
|
if (config.alwaysOnTop) {
|
||||||
win.setAlwaysOnTop(true, 'screen-saver', 1);
|
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
window.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
||||||
win.setMaximizable(true);
|
window.setMaximizable(true);
|
||||||
win.setFullScreenable(true);
|
window.setFullScreenable(true);
|
||||||
|
|
||||||
send('pip-toggle', false);
|
send('pip-toggle', false);
|
||||||
|
|
||||||
win.setVisibleOnAllWorkspaces(false);
|
window.setVisibleOnAllWorkspaces(false);
|
||||||
win.setAlwaysOnTop(false);
|
window.setAlwaysOnTop(false);
|
||||||
|
|
||||||
if (originalFullScreen) {
|
if (originalFullScreen) {
|
||||||
win.setFullScreen(true);
|
window.setFullScreen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originalMaximized) {
|
if (originalMaximized) {
|
||||||
win.maximize();
|
window.maximize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
||||||
const [w, h] = isInPiP ? pipSize() : originalSize;
|
const [w, h] = isInPiP ? pipSize() : originalSize;
|
||||||
win.setPosition(x, y);
|
window.setPosition(x, y);
|
||||||
win.setSize(w, h);
|
window.setSize(w, h);
|
||||||
|
|
||||||
win.setWindowButtonVisibility?.(!isInPiP);
|
window.setWindowButtonVisibility?.(!isInPiP);
|
||||||
};
|
};
|
||||||
|
|
||||||
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
||||||
@ -91,30 +87,25 @@ export default builder.createMain(({ getConfig, setConfig, send, handle, on }) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return ({
|
config ??= await getConfig();
|
||||||
async onLoad(window) {
|
setConfig({ isInPiP });
|
||||||
config ??= await getConfig();
|
on('picture-in-picture', () => {
|
||||||
win ??= window;
|
togglePiP();
|
||||||
setConfig({ isInPiP });
|
});
|
||||||
on('picture-in-picture', () => {
|
|
||||||
togglePiP();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.on('move', () => {
|
window.on('move', () => {
|
||||||
if (config.isInPiP && !config.useNativePiP) {
|
if (config.isInPiP && !config.useNativePiP) {
|
||||||
setConfig({ 'pip-position': window.getPosition() as [number, number] });
|
setConfig({ 'pip-position': window.getPosition() as [number, number] });
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.on('resize', () => {
|
|
||||||
if (config.isInPiP && !config.useNativePiP) {
|
|
||||||
setConfig({ 'pip-size': window.getSize() as [number, number] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onConfigChange(newConfig) {
|
|
||||||
config = newConfig;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
window.on('resize', () => {
|
||||||
|
if (config.isInPiP && !config.useNativePiP) {
|
||||||
|
setConfig({ 'pip-size': window.getSize() as [number, number] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onConfigChange = (newConfig: PictureInPicturePluginConfig) => {
|
||||||
|
config = newConfig;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import prompt from 'custom-electron-prompt';
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
import builder from './index';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
import type { PictureInPicturePluginConfig } from './index';
|
||||||
|
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
|
|
||||||
export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
export const onMenu = async ({ window, getConfig, setConfig }: MenuContext<PictureInPicturePluginConfig>): Promise<MenuTemplate> => {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -71,4 +74,4 @@ export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
};
|
||||||
|
|||||||
@ -3,12 +3,13 @@ import keyEventAreEqual from 'keyboardevents-areequal';
|
|||||||
|
|
||||||
import pipHTML from './templates/picture-in-picture.html?raw';
|
import pipHTML from './templates/picture-in-picture.html?raw';
|
||||||
|
|
||||||
import builder, { PictureInPicturePluginConfig } from './index';
|
import { getSongMenu } from '@/providers/dom-elements';
|
||||||
|
|
||||||
import { getSongMenu } from '../../providers/dom-elements';
|
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils/renderer';
|
import { ElementFromHtml } from '../utils/renderer';
|
||||||
|
|
||||||
|
import type { PictureInPicturePluginConfig } from './index';
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string) {
|
function $<E extends Element = Element>(selector: string) {
|
||||||
return document.querySelector<E>(selector);
|
return document.querySelector<E>(selector);
|
||||||
}
|
}
|
||||||
@ -133,42 +134,38 @@ const listenForToggle = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const onRendererLoad = async ({ getConfig }: RendererContext<PictureInPicturePluginConfig>) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
export default builder.createRenderer(({ getConfig }) => {
|
useNativePiP = config.useNativePiP;
|
||||||
return {
|
|
||||||
async onLoad() {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
useNativePiP = config.useNativePiP;
|
if (config.hotkey) {
|
||||||
|
const hotkeyEvent = toKeyEvent(config.hotkey);
|
||||||
if (config.hotkey) {
|
window.addEventListener('keydown', (event) => {
|
||||||
const hotkeyEvent = toKeyEvent(config.hotkey);
|
if (
|
||||||
window.addEventListener('keydown', (event) => {
|
keyEventAreEqual(event, hotkeyEvent)
|
||||||
if (
|
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
|
||||||
keyEventAreEqual(event, hotkeyEvent)
|
) {
|
||||||
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
|
togglePictureInPicture();
|
||||||
) {
|
|
||||||
togglePictureInPicture();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
onPlayerApiReady() {
|
}
|
||||||
listenForToggle();
|
};
|
||||||
|
|
||||||
cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
|
export const onPlayerApiReady = () => {
|
||||||
await togglePictureInPicture();
|
listenForToggle();
|
||||||
setTimeout(() => $<HTMLButtonElement>('#player')?.click());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allows easily closing the menu by programmatically clicking outside of it
|
cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
|
||||||
$('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click');
|
await togglePictureInPicture();
|
||||||
// TODO: think about wether an additional button in songMenu is needed
|
setTimeout(() => $<HTMLButtonElement>('#player')?.click());
|
||||||
const popupContainer = $('ytmusic-popup-container');
|
});
|
||||||
if (popupContainer) observer.observe(popupContainer, {
|
|
||||||
childList: true,
|
// Allows easily closing the menu by programmatically clicking outside of it
|
||||||
subtree: true,
|
$('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click');
|
||||||
});
|
// TODO: think about wether an additional button in songMenu is needed
|
||||||
},
|
const popupContainer = $('ytmusic-popup-container');
|
||||||
};
|
if (popupContainer) observer.observe(popupContainer, {
|
||||||
});
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -1,17 +1,14 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { createPlugin } from '@/utils';
|
||||||
|
import { onPlayerApiReady, onUnload } from './renderer';
|
||||||
|
|
||||||
const builder = createPluginBuilder('playback-speed', {
|
export default createPlugin({
|
||||||
name: 'Playback Speed',
|
name: 'Playback Speed',
|
||||||
restartNeeded: false,
|
restartNeeded: false,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
});
|
renderer: {
|
||||||
|
stop: onUnload,
|
||||||
export default builder;
|
onPlayerApiReady,
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
import sliderHTML from './templates/slider.html?raw';
|
import sliderHTML from './templates/slider.html?raw';
|
||||||
|
|
||||||
import builder from './index';
|
import { getSongMenu } from '@/providers/dom-elements';
|
||||||
|
import { singleton } from '@/providers/decorators';
|
||||||
|
|
||||||
import { getSongMenu } from '../../providers/dom-elements';
|
|
||||||
import { ElementFromHtml } from '../utils/renderer';
|
import { ElementFromHtml } from '../utils/renderer';
|
||||||
import { singleton } from '../../providers/decorators';
|
|
||||||
|
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string) {
|
|
||||||
return document.querySelector<E>(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slider = ElementFromHtml(sliderHTML);
|
const slider = ElementFromHtml(sliderHTML);
|
||||||
|
|
||||||
@ -21,12 +15,12 @@ const MAX_PLAYBACK_SPEED = 16;
|
|||||||
let playbackSpeed = 1;
|
let playbackSpeed = 1;
|
||||||
|
|
||||||
const updatePlayBackSpeed = () => {
|
const updatePlayBackSpeed = () => {
|
||||||
const videoElement = $<HTMLVideoElement>('video');
|
const videoElement = document.querySelector<HTMLVideoElement>('video');
|
||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
videoElement.playbackRate = playbackSpeed;
|
videoElement.playbackRate = playbackSpeed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playbackSpeedElement = $('#playback-speed-value');
|
const playbackSpeedElement = document.querySelector('#playback-speed-value');
|
||||||
if (playbackSpeedElement) {
|
if (playbackSpeedElement) {
|
||||||
playbackSpeedElement.innerHTML = String(playbackSpeed);
|
playbackSpeedElement.innerHTML = String(playbackSpeed);
|
||||||
}
|
}
|
||||||
@ -44,7 +38,7 @@ const immediateValueChangedListener = (e: Event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setupSliderListener = singleton(() => {
|
const setupSliderListener = singleton(() => {
|
||||||
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener);
|
document.querySelector('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener);
|
||||||
});
|
});
|
||||||
|
|
||||||
const observePopupContainer = () => {
|
const observePopupContainer = () => {
|
||||||
@ -64,7 +58,7 @@ const observePopupContainer = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const popupContainer = $('ytmusic-popup-container');
|
const popupContainer = document.querySelector('ytmusic-popup-container');
|
||||||
if (popupContainer) {
|
if (popupContainer) {
|
||||||
observer.observe(popupContainer, {
|
observer.observe(popupContainer, {
|
||||||
childList: true,
|
childList: true,
|
||||||
@ -74,7 +68,7 @@ const observePopupContainer = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const observeVideo = () => {
|
const observeVideo = () => {
|
||||||
const video = $<HTMLVideoElement>('video');
|
const video = document.querySelector<HTMLVideoElement>('video');
|
||||||
if (video) {
|
if (video) {
|
||||||
video.addEventListener('ratechange', forcePlaybackRate);
|
video.addEventListener('ratechange', forcePlaybackRate);
|
||||||
video.addEventListener('srcChanged', forcePlaybackRate);
|
video.addEventListener('srcChanged', forcePlaybackRate);
|
||||||
@ -95,7 +89,7 @@ const wheelEventListener = (e: WheelEvent) => {
|
|||||||
|
|
||||||
updatePlayBackSpeed();
|
updatePlayBackSpeed();
|
||||||
// Update slider position
|
// Update slider position
|
||||||
const playbackSpeedSilder = $<HTMLElement & { value: number }>('#playback-speed-slider');
|
const playbackSpeedSilder = document.querySelector<HTMLElement & { value: number }>('#playback-speed-slider');
|
||||||
if (playbackSpeedSilder) {
|
if (playbackSpeedSilder) {
|
||||||
playbackSpeedSilder.value = playbackSpeed;
|
playbackSpeedSilder.value = playbackSpeed;
|
||||||
}
|
}
|
||||||
@ -114,22 +108,19 @@ function forcePlaybackRate(e: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default builder.createRenderer(() => {
|
export const onPlayerApiReady = () => {
|
||||||
return {
|
observePopupContainer();
|
||||||
onPlayerApiReady() {
|
observeVideo();
|
||||||
observePopupContainer();
|
setupWheelListener();
|
||||||
observeVideo();
|
};
|
||||||
setupWheelListener();
|
|
||||||
},
|
export const onUnload = () => {
|
||||||
onUnload() {
|
const video = document.querySelector<HTMLVideoElement>('video');
|
||||||
const video = $<HTMLVideoElement>('video');
|
if (video) {
|
||||||
if (video) {
|
video.removeEventListener('ratechange', forcePlaybackRate);
|
||||||
video.removeEventListener('ratechange', forcePlaybackRate);
|
video.removeEventListener('srcChanged', forcePlaybackRate);
|
||||||
video.removeEventListener('srcChanged', forcePlaybackRate);
|
}
|
||||||
}
|
slider.removeEventListener('wheel', wheelEventListener);
|
||||||
slider.removeEventListener('wheel', wheelEventListener);
|
getSongMenu()?.removeChild(slider);
|
||||||
getSongMenu()?.removeChild(slider);
|
document.querySelector('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener);
|
||||||
$('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener);
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import hudStyle from './volume-hud.css?inline';
|
import { globalShortcut, MenuItem } from 'electron';
|
||||||
|
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
||||||
|
|
||||||
import { createPluginBuilder } from '../utils/builder';
|
import hudStyle from './volume-hud.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
import { overrideListener } from './override';
|
||||||
|
import { onConfigChange, onPlayerApiReady } from './renderer';
|
||||||
|
|
||||||
export type PreciseVolumePluginConfig = {
|
export type PreciseVolumePluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -13,7 +19,7 @@ export type PreciseVolumePluginConfig = {
|
|||||||
savedVolume: number | undefined;
|
savedVolume: number | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const builder = createPluginBuilder('precise-volume', {
|
export default createPlugin({
|
||||||
name: 'Precise Volume',
|
name: 'Precise Volume',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -26,13 +32,107 @@ const builder = createPluginBuilder('precise-volume', {
|
|||||||
},
|
},
|
||||||
savedVolume: undefined, // Plugin save volume between session here
|
savedVolume: undefined, // Plugin save volume between session here
|
||||||
} as PreciseVolumePluginConfig,
|
} as PreciseVolumePluginConfig,
|
||||||
styles: [hudStyle],
|
stylesheets: [hudStyle],
|
||||||
});
|
menu: async ({ setConfig, getConfig, window }) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
export default builder;
|
function changeOptions(changedOptions: Partial<PreciseVolumePluginConfig>, options: PreciseVolumePluginConfig) {
|
||||||
|
for (const option in changedOptions) {
|
||||||
|
// HACK: Weird TypeScript error
|
||||||
|
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
setConfig(options);
|
||||||
interface PluginBuilderList {
|
}
|
||||||
[builder.id]: typeof builder;
|
|
||||||
|
// Helper function for globalShortcuts prompt
|
||||||
|
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined });
|
||||||
|
|
||||||
|
async function promptVolumeSteps(options: PreciseVolumePluginConfig) {
|
||||||
|
const output = await prompt({
|
||||||
|
title: 'Volume Steps',
|
||||||
|
label: 'Choose Volume Increase/Decrease Steps',
|
||||||
|
value: options.steps || 1,
|
||||||
|
type: 'counter',
|
||||||
|
counterOptions: { minimum: 0, maximum: 100, multiFire: true },
|
||||||
|
width: 380,
|
||||||
|
...promptOptions(),
|
||||||
|
}, window);
|
||||||
|
|
||||||
|
if (output || output === 0) { // 0 is somewhat valid
|
||||||
|
changeOptions({ steps: output }, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptGlobalShortcuts(options: PreciseVolumePluginConfig, item: MenuItem) {
|
||||||
|
const output = await prompt({
|
||||||
|
title: 'Global Volume Keybinds',
|
||||||
|
label: 'Choose Global Volume Keybinds:',
|
||||||
|
type: 'keybind',
|
||||||
|
keybindOptions: [
|
||||||
|
kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp),
|
||||||
|
kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown),
|
||||||
|
],
|
||||||
|
...promptOptions(),
|
||||||
|
}, window);
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
const newGlobalShortcuts: {
|
||||||
|
volumeUp: string;
|
||||||
|
volumeDown: string;
|
||||||
|
} = { volumeUp: '', volumeDown: '' };
|
||||||
|
for (const { value, accelerator } of output) {
|
||||||
|
newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeOptions({ globalShortcuts: newGlobalShortcuts }, options);
|
||||||
|
|
||||||
|
item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown);
|
||||||
|
} else {
|
||||||
|
// Reset checkbox if prompt was canceled
|
||||||
|
item.checked = !item.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Local Arrowkeys Controls',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: Boolean(config.arrowsShortcut),
|
||||||
|
click(item) {
|
||||||
|
changeOptions({ arrowsShortcut: item.checked }, config);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Global Hotkeys',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: Boolean(config.globalShortcuts?.volumeUp ?? config.globalShortcuts?.volumeDown),
|
||||||
|
click: (item) => promptGlobalShortcuts(config, item),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Set Custom Volume Steps',
|
||||||
|
click: () => promptVolumeSteps(config),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
async backend({ getConfig, ipc }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
if (config.globalShortcuts?.volumeUp) {
|
||||||
|
globalShortcut.register(config.globalShortcuts.volumeUp, () => ipc.send('changeVolume', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.globalShortcuts?.volumeDown) {
|
||||||
|
globalShortcut.register(config.globalShortcuts.volumeDown, () => ipc.send('changeVolume', false));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
start() {
|
||||||
|
overrideListener();
|
||||||
|
},
|
||||||
|
onPlayerApiReady,
|
||||||
|
onConfigChange,
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import { BrowserWindow, MenuItem } from 'electron';
|
|
||||||
|
|
||||||
import builder, { PreciseVolumePluginConfig } from './index';
|
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
|
|
||||||
export default builder.createMenu(async ({ setConfig, getConfig, window }) => {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
function changeOptions(changedOptions: Partial<PreciseVolumePluginConfig>, options: PreciseVolumePluginConfig, win: BrowserWindow) {
|
|
||||||
for (const option in changedOptions) {
|
|
||||||
// HACK: Weird TypeScript error
|
|
||||||
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfig(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function for globalShortcuts prompt
|
|
||||||
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined });
|
|
||||||
|
|
||||||
async function promptVolumeSteps(win: BrowserWindow, options: PreciseVolumePluginConfig) {
|
|
||||||
const output = await prompt({
|
|
||||||
title: 'Volume Steps',
|
|
||||||
label: 'Choose Volume Increase/Decrease Steps',
|
|
||||||
value: options.steps || 1,
|
|
||||||
type: 'counter',
|
|
||||||
counterOptions: { minimum: 0, maximum: 100, multiFire: true },
|
|
||||||
width: 380,
|
|
||||||
...promptOptions(),
|
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (output || output === 0) { // 0 is somewhat valid
|
|
||||||
changeOptions({ steps: output }, options, win);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promptGlobalShortcuts(win: BrowserWindow, options: PreciseVolumePluginConfig, item: MenuItem) {
|
|
||||||
const output = await prompt({
|
|
||||||
title: 'Global Volume Keybinds',
|
|
||||||
label: 'Choose Global Volume Keybinds:',
|
|
||||||
type: 'keybind',
|
|
||||||
keybindOptions: [
|
|
||||||
kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp),
|
|
||||||
kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown),
|
|
||||||
],
|
|
||||||
...promptOptions(),
|
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (output) {
|
|
||||||
const newGlobalShortcuts: {
|
|
||||||
volumeUp: string;
|
|
||||||
volumeDown: string;
|
|
||||||
} = { volumeUp: '', volumeDown: '' };
|
|
||||||
for (const { value, accelerator } of output) {
|
|
||||||
newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator;
|
|
||||||
}
|
|
||||||
|
|
||||||
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win);
|
|
||||||
|
|
||||||
item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown);
|
|
||||||
} else {
|
|
||||||
// Reset checkbox if prompt was canceled
|
|
||||||
item.checked = !item.checked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Local Arrowkeys Controls',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: Boolean(config.arrowsShortcut),
|
|
||||||
click(item) {
|
|
||||||
changeOptions({ arrowsShortcut: item.checked }, config, window);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Global Hotkeys',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: Boolean(config.globalShortcuts?.volumeUp ?? config.globalShortcuts?.volumeDown),
|
|
||||||
click: (item) => promptGlobalShortcuts(window, config, item),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Set Custom Volume Steps',
|
|
||||||
click: () => promptVolumeSteps(window, config),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
/* what */
|
/* what */
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
||||||
|
|
||||||
const ignored = {
|
const ignored = {
|
||||||
id: ['volume-slider', 'expand-volume-slider'],
|
id: ['volume-slider', 'expand-volume-slider'],
|
||||||
@ -9,7 +8,8 @@ const ignored = {
|
|||||||
function overrideAddEventListener() {
|
function overrideAddEventListener() {
|
||||||
// YO WHAT ARE YOU DOING NOW?!?!
|
// YO WHAT ARE YOU DOING NOW?!?!
|
||||||
// Save native addEventListener
|
// Save native addEventListener
|
||||||
// @ts-ignore
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error - We know what we're doing
|
||||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
Element.prototype._addEventListener = Element.prototype.addEventListener;
|
Element.prototype._addEventListener = Element.prototype.addEventListener;
|
||||||
// Override addEventListener to Ignore specific events in volume-slider
|
// Override addEventListener to Ignore specific events in volume-slider
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { overrideListener } from './override';
|
import { type PreciseVolumePluginConfig } from './index';
|
||||||
|
|
||||||
import builder, { type PreciseVolumePluginConfig } from './index';
|
import { debounce } from '@/providers/decorators';
|
||||||
|
|
||||||
import { debounce } from '../../providers/decorators';
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string) {
|
function $<E extends Element = Element>(selector: string) {
|
||||||
return document.querySelector<E>(selector);
|
return document.querySelector<E>(selector);
|
||||||
@ -23,12 +22,14 @@ export const moveVolumeHud = debounce((showVideo: boolean) => {
|
|||||||
: '0';
|
: '0';
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
let options: PreciseVolumePluginConfig;
|
||||||
let options: PreciseVolumePluginConfig = await getConfig();
|
|
||||||
|
export const onPlayerApiReady = (playerApi: YoutubePlayer, context: RendererContext<PreciseVolumePluginConfig>) => {
|
||||||
|
api = playerApi;
|
||||||
|
|
||||||
// Without this function it would rewrite config 20 time when volume change by 20
|
// Without this function it would rewrite config 20 time when volume change by 20
|
||||||
const writeOptions = debounce(() => {
|
const writeOptions = debounce(() => {
|
||||||
setConfig(options);
|
context.setConfig(options);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
|
const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
|
||||||
@ -254,20 +255,12 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.ipc.on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease));
|
||||||
|
context.ipc.on('setVolume', (value: number) => setVolume(value));
|
||||||
|
|
||||||
return {
|
firstRun();
|
||||||
onLoad() {
|
};
|
||||||
overrideListener();
|
|
||||||
},
|
|
||||||
onPlayerApiReady(playerApi) {
|
|
||||||
api = playerApi;
|
|
||||||
|
|
||||||
on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease));
|
export const onConfigChange = (config: PreciseVolumePluginConfig) => {
|
||||||
on('setVolume', (value: number) => setVolume(value));
|
options = config;
|
||||||
firstRun();
|
};
|
||||||
},
|
|
||||||
onConfigChange(config) {
|
|
||||||
options = config;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,17 +1,65 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { dialog } from 'electron';
|
||||||
|
|
||||||
const builder = createPluginBuilder('quality-changer', {
|
import QualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||||
|
|
||||||
|
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
name: 'Video Quality Changer',
|
name: 'Video Quality Changer',
|
||||||
restartNeeded: false,
|
restartNeeded: false,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
backend({ ipc, window }) {
|
||||||
|
ipc.handle('qualityChanger', async (qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(window, {
|
||||||
|
type: 'question',
|
||||||
|
buttons: qualityLabels,
|
||||||
|
defaultId: currentIndex,
|
||||||
|
title: 'Choose Video Quality',
|
||||||
|
message: 'Choose Video Quality:',
|
||||||
|
detail: `Current Quality: ${qualityLabels[currentIndex]}`,
|
||||||
|
cancelId: -1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
qualitySettingsButton: ElementFromHtml(QualitySettingsTemplate),
|
||||||
|
onPlayerApiReady(api: YoutubePlayer, context) {
|
||||||
|
const getPlayer = () => document.querySelector<HTMLVideoElement>('#player');
|
||||||
|
const chooseQuality = () => {
|
||||||
|
setTimeout(() => getPlayer()?.click());
|
||||||
|
|
||||||
|
const qualityLevels = api.getAvailableQualityLevels();
|
||||||
|
|
||||||
|
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
|
||||||
|
|
||||||
|
(context.ipc.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex) as Promise<{ response: number }>)
|
||||||
|
.then((promise) => {
|
||||||
|
if (promise.response === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuality = qualityLevels[promise.response];
|
||||||
|
api.setPlaybackQualityRange(newQuality);
|
||||||
|
api.setPlaybackQuality(newQuality);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
document.querySelector('.top-row-buttons.ytmusic-player')?.prepend(this.qualitySettingsButton);
|
||||||
|
|
||||||
|
this.qualitySettingsButton.addEventListener('click', chooseQuality);
|
||||||
|
};
|
||||||
|
|
||||||
|
setup();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
document.querySelector('.top-row-buttons.ytmusic-player')?.removeChild(this.qualitySettingsButton);
|
||||||
|
},
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default builder;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { createPlugin } from '@/utils';
|
||||||
|
import { onMainLoad } from './main';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
|
||||||
export type ShortcutMappingType = {
|
export type ShortcutMappingType = {
|
||||||
previous: string;
|
previous: string;
|
||||||
@ -12,7 +14,7 @@ export type ShortcutsPluginConfig = {
|
|||||||
local: ShortcutMappingType;
|
local: ShortcutMappingType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = createPluginBuilder('shortcuts', {
|
export default createPlugin({
|
||||||
name: 'Shortcuts (& MPRIS)',
|
name: 'Shortcuts (& MPRIS)',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -29,12 +31,7 @@ const builder = createPluginBuilder('shortcuts', {
|
|||||||
next: '',
|
next: '',
|
||||||
},
|
},
|
||||||
} as ShortcutsPluginConfig,
|
} as ShortcutsPluginConfig,
|
||||||
|
menu: onMenu,
|
||||||
|
|
||||||
|
backend: onMainLoad,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default builder;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { BrowserWindow, globalShortcut } from 'electron';
|
import { BrowserWindow, globalShortcut } from 'electron';
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
import electronLocalshortcut from 'electron-localshortcut';
|
import { register as registerElectronLocalShortcut } from 'electron-localshortcut';
|
||||||
|
|
||||||
import registerMPRIS from './mpris';
|
import registerMPRIS from './mpris';
|
||||||
|
import getSongControls from '@/providers/song-controls';
|
||||||
|
|
||||||
import builder, { ShortcutMappingType } from './index';
|
import type { ShortcutMappingType, ShortcutsPluginConfig } from './index';
|
||||||
|
|
||||||
import getSongControls from '../../providers/song-controls';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
|
|
||||||
function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
||||||
@ -16,62 +17,58 @@ function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
||||||
electronLocalshortcut.register(win, shortcut, () => {
|
registerElectronLocalShortcut(win, shortcut, () => {
|
||||||
action(win.webContents);
|
action(win.webContents);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default builder.createMain(({ getConfig }) => {
|
export const onMainLoad = async ({ getConfig, window }: BackendContext<ShortcutsPluginConfig>) => {
|
||||||
return {
|
const config = await getConfig();
|
||||||
async onLoad(win) {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
const songControls = getSongControls(win);
|
const songControls = getSongControls(window);
|
||||||
const { playPause, next, previous, search } = songControls;
|
const { playPause, next, previous, search } = songControls;
|
||||||
|
|
||||||
if (config.overrideMediaKeys) {
|
if (config.overrideMediaKeys) {
|
||||||
_registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause);
|
_registerGlobalShortcut(window.webContents, 'MediaPlayPause', playPause);
|
||||||
_registerGlobalShortcut(win.webContents, 'MediaNextTrack', next);
|
_registerGlobalShortcut(window.webContents, 'MediaNextTrack', next);
|
||||||
_registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous);
|
_registerGlobalShortcut(window.webContents, 'MediaPreviousTrack', previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerLocalShortcut(window, 'CommandOrControl+F', search);
|
||||||
|
_registerLocalShortcut(window, 'CommandOrControl+L', search);
|
||||||
|
|
||||||
|
if (is.linux()) {
|
||||||
|
registerMPRIS(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { global, local } = config;
|
||||||
|
const shortcutOptions = { global, local };
|
||||||
|
|
||||||
|
for (const optionType in shortcutOptions) {
|
||||||
|
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerAllShortcuts(container: ShortcutMappingType, type: string) {
|
||||||
|
for (const _action in container) {
|
||||||
|
// HACK: _action is detected as string, but it's actually a key of ShortcutMappingType
|
||||||
|
const action = _action as keyof ShortcutMappingType;
|
||||||
|
|
||||||
|
if (!container[action]) {
|
||||||
|
continue; // Action accelerator is empty
|
||||||
}
|
}
|
||||||
|
|
||||||
_registerLocalShortcut(win, 'CommandOrControl+F', search);
|
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
|
||||||
_registerLocalShortcut(win, 'CommandOrControl+L', search);
|
const actionCallback: () => void = songControls[action];
|
||||||
|
if (typeof actionCallback !== 'function') {
|
||||||
if (is.linux()) {
|
console.warn('Invalid action', action);
|
||||||
registerMPRIS(win);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { global, local } = config;
|
if (type === 'global') {
|
||||||
const shortcutOptions = { global, local };
|
_registerGlobalShortcut(window.webContents, container[action], actionCallback);
|
||||||
|
} else { // Type === "local"
|
||||||
for (const optionType in shortcutOptions) {
|
_registerLocalShortcut(window, local[action], actionCallback);
|
||||||
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerAllShortcuts(container: ShortcutMappingType, type: string) {
|
|
||||||
for (const _action in container) {
|
|
||||||
// HACK: _action is detected as string, but it's actually a key of ShortcutMappingType
|
|
||||||
const action = _action as keyof ShortcutMappingType;
|
|
||||||
|
|
||||||
if (!container[action]) {
|
|
||||||
continue; // Action accelerator is empty
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
|
|
||||||
const actionCallback: () => void = songControls[action];
|
|
||||||
if (typeof actionCallback !== 'function') {
|
|
||||||
console.warn('Invalid action', action);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'global') {
|
|
||||||
_registerGlobalShortcut(win.webContents, container[action], actionCallback);
|
|
||||||
} else { // Type === "local"
|
|
||||||
_registerLocalShortcut(win, local[action], actionCallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
});
|
};
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
||||||
|
|
||||||
import builder, { ShortcutsPluginConfig } from './index';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
|
|
||||||
|
import type { ShortcutsPluginConfig } from './index';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
export const onMenu = async ({ window, getConfig, setConfig }: MenuContext<ShortcutsPluginConfig>): Promise<MenuTemplate> => {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,4 +53,4 @@ export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
|||||||
click: (item) => setConfig({ overrideMediaKeys: item.checked }),
|
click: (item) => setConfig({ overrideMediaKeys: item.checked }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
};
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import { BrowserWindow, ipcMain } from 'electron';
|
|||||||
|
|
||||||
import mpris, { Track } from '@jellybrick/mpris-service';
|
import mpris, { Track } from '@jellybrick/mpris-service';
|
||||||
|
|
||||||
import registerCallback from '../../providers/song-info';
|
import registerCallback from '@/providers/song-info';
|
||||||
import getSongControls from '../../providers/song-controls';
|
import getSongControls from '@/providers/song-controls';
|
||||||
import config from '../../config';
|
import config from '@/config';
|
||||||
|
|
||||||
function setupMPRIS() {
|
function setupMPRIS() {
|
||||||
const instance = new mpris({
|
const instance = new mpris({
|
||||||
|
|||||||
@ -1,23 +1,20 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { createPlugin } from '@/utils';
|
||||||
|
import { onRendererLoad, onRendererUnload } from './renderer';
|
||||||
|
|
||||||
export type SkipSilencesPluginConfig = {
|
export type SkipSilencesPluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onlySkipBeginning: boolean;
|
onlySkipBeginning: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const builder = createPluginBuilder('skip-silences', {
|
export default createPlugin({
|
||||||
name: 'Skip Silences',
|
name: 'Skip Silences',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
onlySkipBeginning: false,
|
onlySkipBeginning: false,
|
||||||
} as SkipSilencesPluginConfig,
|
} as SkipSilencesPluginConfig,
|
||||||
});
|
renderer: {
|
||||||
|
start: onRendererLoad,
|
||||||
export default builder;
|
stop: onRendererUnload,
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -1,140 +1,139 @@
|
|||||||
import builder, { type SkipSilencesPluginConfig } from './index';
|
import { RendererContext } from '@/types/contexts';
|
||||||
|
|
||||||
export default builder.createRenderer(({ getConfig }) => {
|
import type { SkipSilencesPluginConfig } from './index';
|
||||||
let config: SkipSilencesPluginConfig;
|
|
||||||
|
|
||||||
let isSilent = false;
|
let config: SkipSilencesPluginConfig;
|
||||||
let hasAudioStarted = false;
|
|
||||||
|
|
||||||
const smoothing = 0.1;
|
let isSilent = false;
|
||||||
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
|
let hasAudioStarted = false;
|
||||||
const interval = 2; // Ms
|
|
||||||
const history = 10;
|
|
||||||
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
|
|
||||||
|
|
||||||
let playOrSeekHandler: (() => void) | undefined;
|
const smoothing = 0.1;
|
||||||
|
const threshold = -100; // DB (-100 = absolute silence, 0 = loudest)
|
||||||
|
const interval = 2; // Ms
|
||||||
|
const history = 10;
|
||||||
|
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
|
||||||
|
|
||||||
const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => {
|
let playOrSeekHandler: (() => void) | undefined;
|
||||||
let maxVolume = Number.NEGATIVE_INFINITY;
|
|
||||||
analyser.getFloatFrequencyData(fftBins);
|
|
||||||
|
|
||||||
for (let i = 4, ii = fftBins.length; i < ii; i++) {
|
const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => {
|
||||||
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
|
let maxVolume = Number.NEGATIVE_INFINITY;
|
||||||
maxVolume = fftBins[i];
|
analyser.getFloatFrequencyData(fftBins);
|
||||||
}
|
|
||||||
|
for (let i = 4, ii = fftBins.length; i < ii; i++) {
|
||||||
|
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
|
||||||
|
maxVolume = fftBins[i];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return maxVolume;
|
return maxVolume;
|
||||||
};
|
};
|
||||||
|
|
||||||
const audioCanPlayListener = (e: CustomEvent<Compressor>) => {
|
const audioCanPlayListener = (e: CustomEvent<Compressor>) => {
|
||||||
const video = document.querySelector('video');
|
const video = document.querySelector('video');
|
||||||
const { audioContext } = e.detail;
|
const { audioContext } = e.detail;
|
||||||
const sourceNode = e.detail.audioSource;
|
const sourceNode = e.detail.audioSource;
|
||||||
|
|
||||||
// Use an audio analyser similar to Hark
|
// Use an audio analyser similar to Hark
|
||||||
// https://github.com/otalk/hark/blob/master/hark.bundle.js
|
// https://github.com/otalk/hark/blob/master/hark.bundle.js
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
analyser.fftSize = 512;
|
analyser.fftSize = 512;
|
||||||
analyser.smoothingTimeConstant = smoothing;
|
analyser.smoothingTimeConstant = smoothing;
|
||||||
const fftBins = new Float32Array(analyser.frequencyBinCount);
|
const fftBins = new Float32Array(analyser.frequencyBinCount);
|
||||||
|
|
||||||
sourceNode.connect(analyser);
|
sourceNode.connect(analyser);
|
||||||
analyser.connect(audioContext.destination);
|
analyser.connect(audioContext.destination);
|
||||||
|
|
||||||
const looper = () => {
|
const looper = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const currentVolume = getMaxVolume(analyser, fftBins);
|
const currentVolume = getMaxVolume(analyser, fftBins);
|
||||||
|
|
||||||
let history = 0;
|
let history = 0;
|
||||||
if (currentVolume > threshold && isSilent) {
|
if (currentVolume > threshold && isSilent) {
|
||||||
// Trigger quickly, short history
|
// Trigger quickly, short history
|
||||||
for (
|
for (
|
||||||
let i = speakingHistory.length - 3;
|
let i = speakingHistory.length - 3;
|
||||||
i < speakingHistory.length;
|
i < speakingHistory.length;
|
||||||
i++
|
i++
|
||||||
) {
|
) {
|
||||||
history += speakingHistory[i];
|
history += speakingHistory[i];
|
||||||
}
|
|
||||||
|
|
||||||
if (history >= 2) {
|
|
||||||
// Not silent
|
|
||||||
isSilent = false;
|
|
||||||
hasAudioStarted = true;
|
|
||||||
}
|
|
||||||
} else if (currentVolume < threshold && !isSilent) {
|
|
||||||
for (const element of speakingHistory) {
|
|
||||||
history += element;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (history == 0 // Silent
|
|
||||||
|
|
||||||
&& !(
|
|
||||||
video && (
|
|
||||||
video.paused
|
|
||||||
|| video.seeking
|
|
||||||
|| video.ended
|
|
||||||
|| video.muted
|
|
||||||
|| video.volume === 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
isSilent = true;
|
|
||||||
skipSilence();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
speakingHistory.shift();
|
if (history >= 2) {
|
||||||
speakingHistory.push(Number(currentVolume > threshold));
|
// Not silent
|
||||||
|
isSilent = false;
|
||||||
|
hasAudioStarted = true;
|
||||||
|
}
|
||||||
|
} else if (currentVolume < threshold && !isSilent) {
|
||||||
|
for (const element of speakingHistory) {
|
||||||
|
history += element;
|
||||||
|
}
|
||||||
|
|
||||||
looper();
|
if (history == 0 // Silent
|
||||||
}, interval);
|
|
||||||
};
|
|
||||||
|
|
||||||
looper();
|
&& !(
|
||||||
|
video && (
|
||||||
const skipSilence = () => {
|
video.paused
|
||||||
if (config.onlySkipBeginning && hasAudioStarted) {
|
|| video.seeking
|
||||||
return;
|
|| video.ended
|
||||||
|
|| video.muted
|
||||||
|
|| video.volume === 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
isSilent = true;
|
||||||
|
skipSilence();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSilent && video && !video.paused) {
|
speakingHistory.shift();
|
||||||
video.currentTime += 0.2; // In s
|
speakingHistory.push(Number(currentVolume > threshold));
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
playOrSeekHandler = () => {
|
looper();
|
||||||
hasAudioStarted = false;
|
}, interval);
|
||||||
skipSilence();
|
|
||||||
};
|
|
||||||
|
|
||||||
video?.addEventListener('play', playOrSeekHandler);
|
|
||||||
video?.addEventListener('seeked', playOrSeekHandler);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
looper();
|
||||||
async onLoad() {
|
|
||||||
config = await getConfig();
|
|
||||||
|
|
||||||
document.addEventListener(
|
const skipSilence = () => {
|
||||||
'audioCanPlay',
|
if (config.onlySkipBeginning && hasAudioStarted) {
|
||||||
audioCanPlayListener,
|
return;
|
||||||
{
|
}
|
||||||
passive: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onUnload() {
|
|
||||||
document.removeEventListener(
|
|
||||||
'audioCanPlay',
|
|
||||||
audioCanPlayListener,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (playOrSeekHandler) {
|
if (isSilent && video && !video.paused) {
|
||||||
const video = document.querySelector('video');
|
video.currentTime += 0.2; // In s
|
||||||
video?.removeEventListener('play', playOrSeekHandler);
|
|
||||||
video?.removeEventListener('seeked', playOrSeekHandler);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
playOrSeekHandler = () => {
|
||||||
|
hasAudioStarted = false;
|
||||||
|
skipSilence();
|
||||||
|
};
|
||||||
|
|
||||||
|
video?.addEventListener('play', playOrSeekHandler);
|
||||||
|
video?.addEventListener('seeked', playOrSeekHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onRendererLoad = async ({ getConfig }: RendererContext<SkipSilencesPluginConfig>) => {
|
||||||
|
config = await getConfig();
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'audioCanPlay',
|
||||||
|
audioCanPlayListener,
|
||||||
|
{
|
||||||
|
passive: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onRendererUnload = () => {
|
||||||
|
document.removeEventListener(
|
||||||
|
'audioCanPlay',
|
||||||
|
audioCanPlayListener,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playOrSeekHandler) {
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
video?.removeEventListener('play', playOrSeekHandler);
|
||||||
|
video?.removeEventListener('seeked', playOrSeekHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import is from 'electron-is';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import { sortSegments } from './segments';
|
||||||
|
|
||||||
|
import type { GetPlayerResponse } from '@/types/get-player-response';
|
||||||
|
import type { Segment, SkipSegment } from './types';
|
||||||
|
|
||||||
export type SponsorBlockPluginConfig = {
|
export type SponsorBlockPluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -6,7 +13,9 @@ export type SponsorBlockPluginConfig = {
|
|||||||
categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[];
|
categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const builder = createPluginBuilder('sponsorblock', {
|
let currentSegments: Segment[] = [];
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
name: 'SponsorBlock',
|
name: 'SponsorBlock',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -21,12 +30,83 @@ const builder = createPluginBuilder('sponsorblock', {
|
|||||||
'music_offtopic',
|
'music_offtopic',
|
||||||
],
|
],
|
||||||
} as SponsorBlockPluginConfig,
|
} as SponsorBlockPluginConfig,
|
||||||
});
|
async backend({ getConfig, ipc }) {
|
||||||
|
const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
|
||||||
|
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
|
||||||
|
categories,
|
||||||
|
)}`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(sponsorBlockURL, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
export default builder;
|
const segments = await resp.json() as SkipSegment[];
|
||||||
|
return sortSegments(
|
||||||
|
segments.map((submission) => submission.segment),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (is.dev()) {
|
||||||
|
console.log('error on sponsorblock request:', error);
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
return [];
|
||||||
interface PluginBuilderList {
|
}
|
||||||
[builder.id]: typeof builder;
|
};
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
const { apiURL, categories } = config;
|
||||||
|
|
||||||
|
ipc.on('video-src-changed', async (data: GetPlayerResponse) => {
|
||||||
|
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
|
||||||
|
ipc.send('sponsorblock-skip', segments);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
timeUpdateListener: (e: Event) => {
|
||||||
|
if (e.target instanceof HTMLVideoElement) {
|
||||||
|
const target = e.target;
|
||||||
|
|
||||||
|
for (const segment of currentSegments) {
|
||||||
|
if (
|
||||||
|
target.currentTime >= segment[0]
|
||||||
|
&& target.currentTime < segment[1]
|
||||||
|
) {
|
||||||
|
target.currentTime = segment[1];
|
||||||
|
if (window.electronIs.dev()) {
|
||||||
|
console.log('SponsorBlock: skipping segment', segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetSegments: () => currentSegments = [],
|
||||||
|
start({ ipc }) {
|
||||||
|
ipc.on('sponsorblock-skip', (segments: Segment[]) => {
|
||||||
|
currentSegments = segments;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPlayerApiReady() {
|
||||||
|
const video = document.querySelector<HTMLVideoElement>('video');
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
video.addEventListener('timeupdate', this.timeUpdateListener);
|
||||||
|
// Reset segments on song end
|
||||||
|
video.addEventListener('emptied', this.resetSegments);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
const video = document.querySelector<HTMLVideoElement>('video');
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
video.removeEventListener('timeupdate', this.timeUpdateListener);
|
||||||
|
video.removeEventListener('emptied', this.resetSegments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { Segment } from './types';
|
|
||||||
import builder from './index';
|
|
||||||
|
|
||||||
export default builder.createRenderer(({ on }) => {
|
|
||||||
let currentSegments: Segment[] = [];
|
|
||||||
|
|
||||||
const timeUpdateListener = (e: Event) => {
|
|
||||||
if (e.target instanceof HTMLVideoElement) {
|
|
||||||
const target = e.target;
|
|
||||||
|
|
||||||
for (const segment of currentSegments) {
|
|
||||||
if (
|
|
||||||
target.currentTime >= segment[0]
|
|
||||||
&& target.currentTime < segment[1]
|
|
||||||
) {
|
|
||||||
target.currentTime = segment[1];
|
|
||||||
if (window.electronIs.dev()) {
|
|
||||||
console.log('SponsorBlock: skipping segment', segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSegments = () => currentSegments = [];
|
|
||||||
|
|
||||||
return ({
|
|
||||||
onLoad() {
|
|
||||||
on('sponsorblock-skip', (_, segments: Segment[]) => {
|
|
||||||
currentSegments = segments;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onPlayerApiReady() {
|
|
||||||
const video = document.querySelector<HTMLVideoElement>('video');
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
video.addEventListener('timeupdate', timeUpdateListener);
|
|
||||||
// Reset segments on song end
|
|
||||||
video.addEventListener('emptied', resetSegments);
|
|
||||||
},
|
|
||||||
onUnload() {
|
|
||||||
const video = document.querySelector<HTMLVideoElement>('video');
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
video.removeEventListener('timeupdate', timeUpdateListener);
|
|
||||||
video.removeEventListener('emptied', resetSegments);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,17 +1,84 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
|
||||||
|
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
|
||||||
|
import nextIcon from '@assets/media-icons-black/next.png?asset&asarUnpack';
|
||||||
|
import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpack';
|
||||||
|
|
||||||
const builder = createPluginBuilder('taskbar-mediacontrol', {
|
import { nativeImage } from 'electron';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import getSongControls from '@/providers/song-controls';
|
||||||
|
import registerCallback, { type SongInfo } from '@/providers/song-info';
|
||||||
|
import { mediaIcons } from '@/types/media-icons';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
name: 'Taskbar Media Control',
|
name: 'Taskbar Media Control',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
|
||||||
export default builder;
|
backend({ window }) {
|
||||||
|
let currentSongInfo: SongInfo;
|
||||||
|
|
||||||
declare global {
|
const { playPause, next, previous } = getSongControls(window);
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
const setThumbar = (songInfo: SongInfo) => {
|
||||||
|
// Wait for song to start before setting thumbar
|
||||||
|
if (!songInfo?.title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win32 require full rewrite of components
|
||||||
|
window.setThumbarButtons([
|
||||||
|
{
|
||||||
|
tooltip: 'Previous',
|
||||||
|
icon: nativeImage.createFromPath(get('previous')),
|
||||||
|
click() {
|
||||||
|
previous();
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
tooltip: 'Play/Pause',
|
||||||
|
// Update icon based on play state
|
||||||
|
icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
|
||||||
|
click() {
|
||||||
|
playPause();
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
tooltip: 'Next',
|
||||||
|
icon: nativeImage.createFromPath(get('next')),
|
||||||
|
click() {
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Util
|
||||||
|
const get = (kind: keyof typeof mediaIcons): string => {
|
||||||
|
switch (kind) {
|
||||||
|
case 'play':
|
||||||
|
return playIcon;
|
||||||
|
case 'pause':
|
||||||
|
return pauseIcon;
|
||||||
|
case 'next':
|
||||||
|
return nextIcon;
|
||||||
|
case 'previous':
|
||||||
|
return previousIcon;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
// Update currentsonginfo for win.on('show')
|
||||||
|
currentSongInfo = songInfo;
|
||||||
|
// Update thumbar
|
||||||
|
setThumbar(songInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Need to set thumbar again after win.show
|
||||||
|
window.on('show', () => {
|
||||||
|
setThumbar(currentSongInfo);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,17 +1,98 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { type NativeImage, TouchBar } from 'electron';
|
||||||
|
|
||||||
const builder = createPluginBuilder('touchbar', {
|
import { createPlugin } from '@/utils';
|
||||||
|
import getSongControls from '@/providers/song-controls';
|
||||||
|
import registerCallback from '@/providers/song-info';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
name: 'TouchBar',
|
name: 'TouchBar',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
});
|
backend({ window }) {
|
||||||
|
const {
|
||||||
|
TouchBarButton,
|
||||||
|
TouchBarLabel,
|
||||||
|
TouchBarSpacer,
|
||||||
|
TouchBarSegmentedControl,
|
||||||
|
TouchBarScrubber,
|
||||||
|
} = TouchBar;
|
||||||
|
|
||||||
export default builder;
|
// Songtitle label
|
||||||
|
const songTitle = new TouchBarLabel({
|
||||||
|
label: '',
|
||||||
|
});
|
||||||
|
// This will store the song controls once available
|
||||||
|
let controls: (() => void)[] = [];
|
||||||
|
|
||||||
declare global {
|
// This will store the song image once available
|
||||||
interface PluginBuilderList {
|
const songImage: {
|
||||||
[builder.id]: typeof builder;
|
icon?: NativeImage;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// Pause/play button
|
||||||
|
const pausePlayButton = new TouchBarButton({});
|
||||||
|
|
||||||
|
// The song control buttons (control functions are in the same order)
|
||||||
|
const buttons = new TouchBarSegmentedControl({
|
||||||
|
mode: 'buttons',
|
||||||
|
segments: [
|
||||||
|
new TouchBarButton({
|
||||||
|
label: '⏮',
|
||||||
|
}),
|
||||||
|
pausePlayButton,
|
||||||
|
new TouchBarButton({
|
||||||
|
label: '⏭',
|
||||||
|
}),
|
||||||
|
new TouchBarButton({
|
||||||
|
label: '👎',
|
||||||
|
}),
|
||||||
|
new TouchBarButton({
|
||||||
|
label: '👍',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
change: (i) => controls[i](),
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is the touchbar object, this combines everything with proper layout
|
||||||
|
const touchBar = new TouchBar({
|
||||||
|
items: [
|
||||||
|
new TouchBarScrubber({
|
||||||
|
items: [songImage, songTitle],
|
||||||
|
continuous: false,
|
||||||
|
}),
|
||||||
|
new TouchBarSpacer({
|
||||||
|
size: 'flexible',
|
||||||
|
}),
|
||||||
|
buttons,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { playPause, next, previous, dislike, like } = getSongControls(window);
|
||||||
|
|
||||||
|
// If the page is ready, register the callback
|
||||||
|
window.once('ready-to-show', () => {
|
||||||
|
controls = [previous, playPause, next, dislike, like];
|
||||||
|
|
||||||
|
// Register the callback
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
// Song information changed, so lets update the touchBar
|
||||||
|
|
||||||
|
// Set the song title
|
||||||
|
songTitle.label = songInfo.title;
|
||||||
|
|
||||||
|
// Changes the pause button if paused
|
||||||
|
pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸';
|
||||||
|
|
||||||
|
// Get image source
|
||||||
|
songImage.icon = songInfo.image
|
||||||
|
? songInfo.image.resize({ height: 23 })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
window.setTouchBar(touchBar);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,17 +1,89 @@
|
|||||||
import { createPluginBuilder } from '../utils/builder';
|
import { net } from 'electron';
|
||||||
|
|
||||||
const builder = createPluginBuilder('tuna-obs', {
|
import is from 'electron-is';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import registerCallback from '@/providers/song-info';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
album: string | null | undefined;
|
||||||
|
album_url: string;
|
||||||
|
artists: string[];
|
||||||
|
cover: string;
|
||||||
|
cover_url: string;
|
||||||
|
duration: number;
|
||||||
|
progress: number;
|
||||||
|
status: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
name: 'Tuna OBS',
|
name: 'Tuna OBS',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
});
|
backend: {
|
||||||
|
data: {
|
||||||
|
cover: '',
|
||||||
|
cover_url: '',
|
||||||
|
title: '',
|
||||||
|
artists: [] as string[],
|
||||||
|
status: '',
|
||||||
|
progress: 0,
|
||||||
|
duration: 0,
|
||||||
|
album_url: '',
|
||||||
|
album: undefined,
|
||||||
|
} as Data,
|
||||||
|
start({ ipc }) {
|
||||||
|
const secToMilisec = (t: number) => Math.round(Number(t) * 1e3);
|
||||||
|
|
||||||
export default builder;
|
const post = (data: Data) => {
|
||||||
|
const port = 1608;
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Access-Control-Allow-Headers': '*',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
};
|
||||||
|
const url = `http://127.0.0.1:${port}/`;
|
||||||
|
net.fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ data }),
|
||||||
|
}).catch((error: { code: number, errno: number }) => {
|
||||||
|
if (is.dev()) {
|
||||||
|
console.debug(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
ipc.on('ytmd:player-api-loaded', () => ipc.send('setupTimeChangedListener'));
|
||||||
interface PluginBuilderList {
|
ipc.on('timeChanged', (t: number) => {
|
||||||
[builder.id]: typeof builder;
|
if (!this.data.title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data.progress = secToMilisec(t);
|
||||||
|
post(this.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
if (!songInfo.title && !songInfo.artist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data.duration = secToMilisec(songInfo.songDuration);
|
||||||
|
this.data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0);
|
||||||
|
this.data.cover = songInfo.imageSrc ?? '';
|
||||||
|
this.data.cover_url = songInfo.imageSrc ?? '';
|
||||||
|
this.data.album_url = songInfo.imageSrc ?? '';
|
||||||
|
this.data.title = songInfo.title;
|
||||||
|
this.data.artists = [songInfo.artist];
|
||||||
|
this.data.status = songInfo.isPaused ? 'stopped' : 'playing';
|
||||||
|
this.data.album = songInfo.album;
|
||||||
|
post(this.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,7 +1,12 @@
|
|||||||
|
import buttonTemplate from './templates/button_template.html?raw';
|
||||||
import forceHideStyle from './force-hide.css?inline';
|
import forceHideStyle from './force-hide.css?inline';
|
||||||
import buttonSwitcherStyle from './button-switcher.css?inline';
|
import buttonSwitcherStyle from './button-switcher.css?inline';
|
||||||
|
|
||||||
import { createPluginBuilder } from '../utils/builder';
|
import { createPlugin } from '@/utils';
|
||||||
|
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '@/plugins/precise-volume/renderer';
|
||||||
|
import { YoutubePlayer } from '@/types/youtube-player';
|
||||||
|
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||||
|
import { ThumbnailElement } from '@/types/get-player-response';
|
||||||
|
|
||||||
export type VideoTogglePluginConfig = {
|
export type VideoTogglePluginConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -11,7 +16,7 @@ export type VideoTogglePluginConfig = {
|
|||||||
align: 'left' | 'middle' | 'right';
|
align: 'left' | 'middle' | 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = createPluginBuilder('video-toggle', {
|
export default createPlugin({
|
||||||
name: 'Video Toggle',
|
name: 'Video Toggle',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -21,16 +26,273 @@ const builder = createPluginBuilder('video-toggle', {
|
|||||||
forceHide: false,
|
forceHide: false,
|
||||||
align: 'left',
|
align: 'left',
|
||||||
} as VideoTogglePluginConfig,
|
} as VideoTogglePluginConfig,
|
||||||
styles: [
|
stylesheets: [
|
||||||
buttonSwitcherStyle,
|
buttonSwitcherStyle,
|
||||||
forceHideStyle,
|
forceHideStyle,
|
||||||
],
|
],
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Mode',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Custom toggle',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.mode === 'custom',
|
||||||
|
click() {
|
||||||
|
setConfig({ mode: 'custom' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Native toggle',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.mode === 'native',
|
||||||
|
click() {
|
||||||
|
setConfig({ mode: 'native' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Disabled',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.mode === 'disabled',
|
||||||
|
click() {
|
||||||
|
setConfig({ mode: 'disabled' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Alignment',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Left',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.align === 'left',
|
||||||
|
click() {
|
||||||
|
setConfig({ align: 'left' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Middle',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.align === 'middle',
|
||||||
|
click() {
|
||||||
|
setConfig({ align: 'middle' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Right',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.align === 'right',
|
||||||
|
click() {
|
||||||
|
setConfig({ align: 'right' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Force Remove Video Tab',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.forceHide,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ forceHide: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
config: null as VideoTogglePluginConfig | null,
|
||||||
|
applyStyleClass: (config: VideoTogglePluginConfig) => {
|
||||||
|
if (config.forceHide) {
|
||||||
|
document.body.classList.add('video-toggle-force-hide');
|
||||||
|
document.body.classList.remove('video-toggle-custom-mode');
|
||||||
|
} else if (!config.mode || config.mode === 'custom') {
|
||||||
|
document.body.classList.add('video-toggle-custom-mode');
|
||||||
|
document.body.classList.remove('video-toggle-force-hide');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async start({ getConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
this.applyStyleClass(config);
|
||||||
|
|
||||||
|
if (config.forceHide) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (config.mode) {
|
||||||
|
case 'native': {
|
||||||
|
document.querySelector('ytmusic-player-page')?.setAttribute('has-av-switcher', '');
|
||||||
|
document.querySelector('ytmusic-player')?.setAttribute('has-av-switcher', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'disabled': {
|
||||||
|
document.querySelector('ytmusic-player-page')?.removeAttribute('has-av-switcher');
|
||||||
|
document.querySelector('ytmusic-player')?.removeAttribute('has-av-switcher');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPlayerApiReady(api, { getConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
|
const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ?
|
||||||
|
preciseVolumeMoveVolumeHud as (_: boolean) => void
|
||||||
|
: (() => {});
|
||||||
|
|
||||||
|
const player = document.querySelector<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player');
|
||||||
|
const video = document.querySelector<HTMLVideoElement>('video');
|
||||||
|
|
||||||
|
const switchButtonDiv = ElementFromHtml(buttonTemplate);
|
||||||
|
|
||||||
|
const forceThumbnail = (img: HTMLImageElement) => {
|
||||||
|
const thumbnails: ThumbnailElement[] = (document.querySelector('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? [];
|
||||||
|
if (thumbnails && thumbnails.length > 0) {
|
||||||
|
const thumbnail = thumbnails.at(-1)?.url.split('?')[0];
|
||||||
|
if (thumbnail) img.src = thumbnail;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVideoState = (showVideo: boolean) => {
|
||||||
|
if (this.config) {
|
||||||
|
this.config.hideVideo = !showVideo;
|
||||||
|
}
|
||||||
|
window.mainConfig.plugins.setOptions('video-toggle', config);
|
||||||
|
|
||||||
|
const checkbox = document.querySelector<HTMLInputElement>('.video-switch-button-checkbox'); // custom mode
|
||||||
|
if (checkbox) checkbox.checked = !config.hideVideo;
|
||||||
|
|
||||||
|
if (player) {
|
||||||
|
player.style.margin = showVideo ? '' : 'auto 0px';
|
||||||
|
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
|
||||||
|
|
||||||
|
document.querySelector<HTMLElement>('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none';
|
||||||
|
document.querySelector<HTMLElement>('#song-image')!.style.display = showVideo ? 'none' : 'block';
|
||||||
|
|
||||||
|
if (showVideo && video && !video.style.top) {
|
||||||
|
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveVolumeHud(showVideo);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const videoStarted = () => {
|
||||||
|
if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') {
|
||||||
|
// Video doesn't exist -> switch to song mode
|
||||||
|
setVideoState(false);
|
||||||
|
// Hide toggle button
|
||||||
|
switchButtonDiv.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
const songImage = document.querySelector<HTMLImageElement>('#song-image img');
|
||||||
|
if (!songImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Switch to high-res thumbnail
|
||||||
|
forceThumbnail(songImage);
|
||||||
|
// Show toggle button
|
||||||
|
switchButtonDiv.style.display = 'initial';
|
||||||
|
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
|
||||||
|
if (!this.config?.hideVideo && document.querySelector<HTMLElement>('#song-video.ytmusic-player')?.style.display === 'none') {
|
||||||
|
setVideoState(true);
|
||||||
|
} else {
|
||||||
|
moveVolumeHud(!this.config?.hideVideo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container
|
||||||
|
* this function fix the problem by overriding that override :)
|
||||||
|
*/
|
||||||
|
const forcePlaybackMode = () => {
|
||||||
|
if (player) {
|
||||||
|
const playbackModeObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.target instanceof HTMLElement) {
|
||||||
|
const target = mutation.target;
|
||||||
|
if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') {
|
||||||
|
playbackModeObserver.disconnect();
|
||||||
|
target.setAttribute('playback-mode', 'ATV_PREFERRED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const observeThumbnail = () => {
|
||||||
|
const playbackModeObserver = new MutationObserver((mutations) => {
|
||||||
|
if (!player?.videoMode_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.target instanceof HTMLImageElement) {
|
||||||
|
const target = mutation.target;
|
||||||
|
if (!target.src.startsWith('data:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
forceThumbnail(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
playbackModeObserver.observe(document.querySelector('#song-image img')!, { attributeFilter: ['src'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.mode !== 'native' && config.mode != 'disabled') {
|
||||||
|
document.querySelector<HTMLVideoElement>('#player')?.prepend(switchButtonDiv);
|
||||||
|
|
||||||
|
setVideoState(!config.hideVideo);
|
||||||
|
forcePlaybackMode();
|
||||||
|
// Fix black video
|
||||||
|
if (video) {
|
||||||
|
video.style.height = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
//Prevents bubbling to the player which causes it to stop or resume
|
||||||
|
switchButtonDiv.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Button checked = show video
|
||||||
|
switchButtonDiv.addEventListener('change', (e) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
|
||||||
|
setVideoState(target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
video?.addEventListener('srcChanged', videoStarted);
|
||||||
|
|
||||||
|
observeThumbnail();
|
||||||
|
|
||||||
|
switch (config.align) {
|
||||||
|
case 'right': {
|
||||||
|
switchButtonDiv.style.left = 'calc(100% - 240px)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'middle': {
|
||||||
|
switchButtonDiv.style.left = 'calc(50% - 120px)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
case 'left': {
|
||||||
|
switchButtonDiv.style.left = '0px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
this.applyStyleClass(newConfig);
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default builder;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
import buttonTemplate from './templates/button_template.html?raw';
|
|
||||||
|
|
||||||
import builder, { type VideoTogglePluginConfig } from './index';
|
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils/renderer';
|
|
||||||
|
|
||||||
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/renderer';
|
|
||||||
|
|
||||||
import type { ThumbnailElement } from '../../types/get-player-response';
|
|
||||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
|
||||||
|
|
||||||
export default builder.createRenderer(({ getConfig }) => {
|
|
||||||
const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ?
|
|
||||||
preciseVolumeMoveVolumeHud as (_: boolean) => void
|
|
||||||
: (() => {});
|
|
||||||
|
|
||||||
let config: VideoTogglePluginConfig = builder.config;
|
|
||||||
let player: HTMLElement & { videoMode_: boolean } | null;
|
|
||||||
let video: HTMLVideoElement | null;
|
|
||||||
let api: YoutubePlayer;
|
|
||||||
|
|
||||||
const switchButtonDiv = ElementFromHtml(buttonTemplate);
|
|
||||||
|
|
||||||
function setup(playerApi: YoutubePlayer) {
|
|
||||||
api = playerApi;
|
|
||||||
player = document.querySelector<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player');
|
|
||||||
video = document.querySelector<HTMLVideoElement>('video');
|
|
||||||
|
|
||||||
document.querySelector<HTMLVideoElement>('#player')?.prepend(switchButtonDiv);
|
|
||||||
|
|
||||||
setVideoState(!config.hideVideo);
|
|
||||||
forcePlaybackMode();
|
|
||||||
// Fix black video
|
|
||||||
if (video) {
|
|
||||||
video.style.height = 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
//Prevents bubbling to the player which causes it to stop or resume
|
|
||||||
switchButtonDiv.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Button checked = show video
|
|
||||||
switchButtonDiv.addEventListener('change', (e) => {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
|
|
||||||
setVideoState(target.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
video?.addEventListener('srcChanged', videoStarted);
|
|
||||||
|
|
||||||
observeThumbnail();
|
|
||||||
|
|
||||||
switch (config.align) {
|
|
||||||
case 'right': {
|
|
||||||
switchButtonDiv.style.left = 'calc(100% - 240px)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'middle': {
|
|
||||||
switchButtonDiv.style.left = 'calc(50% - 120px)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
case 'left': {
|
|
||||||
switchButtonDiv.style.left = '0px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVideoState(showVideo: boolean) {
|
|
||||||
config.hideVideo = !showVideo;
|
|
||||||
window.mainConfig.plugins.setOptions('video-toggle', config);
|
|
||||||
|
|
||||||
const checkbox = document.querySelector<HTMLInputElement>('.video-switch-button-checkbox'); // custom mode
|
|
||||||
if (checkbox) checkbox.checked = !config.hideVideo;
|
|
||||||
|
|
||||||
if (player) {
|
|
||||||
player.style.margin = showVideo ? '' : 'auto 0px';
|
|
||||||
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
|
|
||||||
|
|
||||||
document.querySelector<HTMLElement>('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none';
|
|
||||||
document.querySelector<HTMLElement>('#song-image')!.style.display = showVideo ? 'none' : 'block';
|
|
||||||
|
|
||||||
if (showVideo && video && !video.style.top) {
|
|
||||||
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
moveVolumeHud(showVideo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function videoStarted() {
|
|
||||||
if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') {
|
|
||||||
// Video doesn't exist -> switch to song mode
|
|
||||||
setVideoState(false);
|
|
||||||
// Hide toggle button
|
|
||||||
switchButtonDiv.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
const songImage = document.querySelector<HTMLImageElement>('#song-image img');
|
|
||||||
if (!songImage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Switch to high-res thumbnail
|
|
||||||
forceThumbnail(songImage);
|
|
||||||
// Show toggle button
|
|
||||||
switchButtonDiv.style.display = 'initial';
|
|
||||||
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
|
|
||||||
if (!config.hideVideo && document.querySelector<HTMLElement>('#song-video.ytmusic-player')?.style.display === 'none') {
|
|
||||||
setVideoState(true);
|
|
||||||
} else {
|
|
||||||
moveVolumeHud(!config.hideVideo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container
|
|
||||||
// this function fix the problem by overriding that override :)
|
|
||||||
function forcePlaybackMode() {
|
|
||||||
if (player) {
|
|
||||||
const playbackModeObserver = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
if (mutation.target instanceof HTMLElement) {
|
|
||||||
const target = mutation.target;
|
|
||||||
if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') {
|
|
||||||
playbackModeObserver.disconnect();
|
|
||||||
target.setAttribute('playback-mode', 'ATV_PREFERRED');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function observeThumbnail() {
|
|
||||||
const playbackModeObserver = new MutationObserver((mutations) => {
|
|
||||||
if (!player?.videoMode_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
if (mutation.target instanceof HTMLImageElement) {
|
|
||||||
const target = mutation.target;
|
|
||||||
if (!target.src.startsWith('data:')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
forceThumbnail(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
playbackModeObserver.observe(document.querySelector('#song-image img')!, { attributeFilter: ['src'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function forceThumbnail(img: HTMLImageElement) {
|
|
||||||
const thumbnails: ThumbnailElement[] = (document.querySelector('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? [];
|
|
||||||
if (thumbnails && thumbnails.length > 0) {
|
|
||||||
const thumbnail = thumbnails.at(-1)?.url.split('?')[0];
|
|
||||||
if (typeof thumbnail === 'string') img.src = thumbnail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyStyleClass = (config: VideoTogglePluginConfig) => {
|
|
||||||
if (config.forceHide) {
|
|
||||||
document.body.classList.add('video-toggle-force-hide');
|
|
||||||
document.body.classList.remove('video-toggle-custom-mode');
|
|
||||||
} else if (!config.mode || config.mode === 'custom') {
|
|
||||||
document.body.classList.add('video-toggle-custom-mode');
|
|
||||||
document.body.classList.remove('video-toggle-force-hide');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
async onLoad() {
|
|
||||||
config = await getConfig();
|
|
||||||
applyStyleClass(config);
|
|
||||||
|
|
||||||
if (config.forceHide) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (config.mode) {
|
|
||||||
case 'native': {
|
|
||||||
document.querySelector('ytmusic-player-page')?.setAttribute('has-av-switcher', '');
|
|
||||||
document.querySelector('ytmusic-player')?.setAttribute('has-av-switcher', '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'disabled': {
|
|
||||||
document.querySelector('ytmusic-player-page')?.removeAttribute('has-av-switcher');
|
|
||||||
document.querySelector('ytmusic-player')?.removeAttribute('has-av-switcher');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPlayerApiReady(playerApi) {
|
|
||||||
if (config.mode !== 'native' && config.mode != 'disabled') setup(playerApi);
|
|
||||||
},
|
|
||||||
onConfigChange(newConfig) {
|
|
||||||
config = newConfig;
|
|
||||||
|
|
||||||
applyStyleClass(newConfig);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,6 +1,11 @@
|
|||||||
import emptyStyle from './empty-player.css?inline';
|
import emptyStyle from './empty-player.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
import { createPluginBuilder } from '../utils/builder';
|
import { Visualizer } from './visualizers/visualizer';
|
||||||
|
import {
|
||||||
|
ButterchurnVisualizer as butterchurn,
|
||||||
|
VudioVisualizer as vudio,
|
||||||
|
WaveVisualizer as wave
|
||||||
|
} from './visualizers';
|
||||||
|
|
||||||
type WaveColor = {
|
type WaveColor = {
|
||||||
gradient: string[];
|
gradient: string[];
|
||||||
@ -51,7 +56,7 @@ export type VisualizerPluginConfig = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const builder = createPluginBuilder('visualizer', {
|
export default createPlugin({
|
||||||
name: 'Visualizer',
|
name: 'Visualizer',
|
||||||
restartNeeded: true,
|
restartNeeded: true,
|
||||||
config: {
|
config: {
|
||||||
@ -120,13 +125,97 @@ const builder = createPluginBuilder('visualizer', {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as VisualizerPluginConfig,
|
} as VisualizerPluginConfig,
|
||||||
styles: [emptyStyle],
|
stylesheets: [emptyStyle],
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
const visualizerTypes = ['butterchurn', 'vudio', 'wave'] as const; // For bundling
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Type',
|
||||||
|
submenu: visualizerTypes.map((visualizerType) => ({
|
||||||
|
label: visualizerType,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.type === visualizerType,
|
||||||
|
click() {
|
||||||
|
setConfig({ type: visualizerType });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
async renderer({ getConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let visualizerType: { new(...args: any[]): Visualizer<unknown> } = vudio;
|
||||||
|
|
||||||
|
if (config.type === 'wave') {
|
||||||
|
visualizerType = wave;
|
||||||
|
} else if (config.type === 'butterchurn') {
|
||||||
|
visualizerType = butterchurn;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'audioCanPlay',
|
||||||
|
(e) => {
|
||||||
|
const video = document.querySelector<HTMLVideoElement & { captureStream(): MediaStream; }>('video');
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visualizerContainer = document.querySelector<HTMLElement>('#player');
|
||||||
|
if (!visualizerContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas = document.querySelector<HTMLCanvasElement>('#visualizer');
|
||||||
|
if (!canvas) {
|
||||||
|
canvas = document.createElement('canvas');
|
||||||
|
canvas.id = 'visualizer';
|
||||||
|
visualizerContainer?.prepend(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeCanvas = () => {
|
||||||
|
if (canvas) {
|
||||||
|
canvas.width = visualizerContainer.clientWidth;
|
||||||
|
canvas.height = visualizerContainer.clientHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resizeCanvas();
|
||||||
|
|
||||||
|
const gainNode = e.detail.audioContext.createGain();
|
||||||
|
gainNode.gain.value = 1.25;
|
||||||
|
e.detail.audioSource.connect(gainNode);
|
||||||
|
|
||||||
|
const visualizer = new visualizerType(
|
||||||
|
e.detail.audioContext,
|
||||||
|
e.detail.audioSource,
|
||||||
|
visualizerContainer,
|
||||||
|
canvas,
|
||||||
|
gainNode,
|
||||||
|
video.captureStream(),
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resizeVisualizer = (width: number, height: number) => {
|
||||||
|
resizeCanvas();
|
||||||
|
visualizer.resize(width, height);
|
||||||
|
};
|
||||||
|
|
||||||
|
resizeVisualizer(canvas.width, canvas.height);
|
||||||
|
const visualizerContainerObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
resizeVisualizer(entry.contentRect.width, entry.contentRect.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
visualizerContainerObserver.observe(visualizerContainer);
|
||||||
|
|
||||||
|
visualizer.render();
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default builder;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface PluginBuilderList {
|
|
||||||
[builder.id]: typeof builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 });
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import { ButterchurnVisualizer as butterchurn, WaveVisualizer as wave, VudioVisualizer as vudio } from './visualizers';
|
|
||||||
import { Visualizer } from './visualizers/visualizer';
|
|
||||||
|
|
||||||
import builder from './index';
|
|
||||||
|
|
||||||
export default builder.createRenderer(({ getConfig }) => {
|
|
||||||
return {
|
|
||||||
async onLoad() {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let visualizerType: { new(...args: any[]): Visualizer<unknown> } = vudio;
|
|
||||||
|
|
||||||
if (config.type === 'wave') {
|
|
||||||
visualizerType = wave;
|
|
||||||
} else if (config.type === 'butterchurn') {
|
|
||||||
visualizerType = butterchurn;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
'audioCanPlay',
|
|
||||||
(e) => {
|
|
||||||
const video = document.querySelector<HTMLVideoElement & { captureStream(): MediaStream; }>('video');
|
|
||||||
if (!video) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visualizerContainer = document.querySelector<HTMLElement>('#player');
|
|
||||||
if (!visualizerContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let canvas = document.querySelector<HTMLCanvasElement>('#visualizer');
|
|
||||||
if (!canvas) {
|
|
||||||
canvas = document.createElement('canvas');
|
|
||||||
canvas.id = 'visualizer';
|
|
||||||
visualizerContainer?.prepend(canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resizeCanvas = () => {
|
|
||||||
if (canvas) {
|
|
||||||
canvas.width = visualizerContainer.clientWidth;
|
|
||||||
canvas.height = visualizerContainer.clientHeight;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
resizeCanvas();
|
|
||||||
|
|
||||||
const gainNode = e.detail.audioContext.createGain();
|
|
||||||
gainNode.gain.value = 1.25;
|
|
||||||
e.detail.audioSource.connect(gainNode);
|
|
||||||
|
|
||||||
const visualizer = new visualizerType(
|
|
||||||
e.detail.audioContext,
|
|
||||||
e.detail.audioSource,
|
|
||||||
visualizerContainer,
|
|
||||||
canvas,
|
|
||||||
gainNode,
|
|
||||||
video.captureStream(),
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
|
|
||||||
const resizeVisualizer = (width: number, height: number) => {
|
|
||||||
resizeCanvas();
|
|
||||||
visualizer.resize(width, height);
|
|
||||||
};
|
|
||||||
|
|
||||||
resizeVisualizer(canvas.width, canvas.height);
|
|
||||||
const visualizerContainerObserver = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
resizeVisualizer(entry.contentRect.width, entry.contentRect.height);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
visualizerContainerObserver.observe(visualizerContainer);
|
|
||||||
|
|
||||||
visualizer.render();
|
|
||||||
},
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import youtubeMusicTrayIcon from '../../assets/youtube-music-tray.png?asset&asarUnpack';
|
import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack';
|
||||||
|
|
||||||
const promptOptions = {
|
const promptOptions = {
|
||||||
customStylesheet: 'dark',
|
customStylesheet: 'dark',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { startingPages } from './providers/extracted-data';
|
import { startingPages } from './providers/extracted-data';
|
||||||
import setupSongInfo from './providers/song-info-front';
|
import setupSongInfo from './providers/song-info-front';
|
||||||
import {
|
import {
|
||||||
|
createContext,
|
||||||
forceLoadRendererPlugin,
|
forceLoadRendererPlugin,
|
||||||
forceUnloadRendererPlugin,
|
forceUnloadRendererPlugin,
|
||||||
getAllLoadedRendererPlugins,
|
getAllLoadedRendererPlugins,
|
||||||
@ -74,10 +75,10 @@ function onApiLoaded() {
|
|||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
Object.values(getAllLoadedRendererPlugins())
|
Object.entries(getAllLoadedRendererPlugins())
|
||||||
.forEach((plugin) => {
|
.forEach(([id, plugin]) => {
|
||||||
if (typeof plugin.renderer !== 'function') {
|
if (typeof plugin.renderer !== 'function') {
|
||||||
plugin.renderer?.onPlayerApiReady?.(api!);
|
plugin.renderer?.onPlayerApiReady?.(api!, createContext(id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -134,7 +135,7 @@ function onApiLoaded() {
|
|||||||
if (api) {
|
if (api) {
|
||||||
const plugin = getLoadedRendererPlugin(id);
|
const plugin = getLoadedRendererPlugin(id);
|
||||||
if (plugin && typeof plugin.renderer !== 'function') {
|
if (plugin && typeof plugin.renderer !== 'function') {
|
||||||
plugin.renderer?.onPlayerApiReady?.(api);
|
plugin.renderer?.onPlayerApiReady?.(api, createContext(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Menu, nativeImage, Tray } from 'electron';
|
import { Menu, nativeImage, Tray } from 'electron';
|
||||||
|
|
||||||
|
import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack';
|
||||||
|
|
||||||
import { restart } from './providers/app-controls';
|
import { restart } from './providers/app-controls';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
import getSongControls from './providers/song-controls';
|
import getSongControls from './providers/song-controls';
|
||||||
|
|
||||||
import youtubeMusicTrayIcon from '../assets/youtube-music-tray.png?asset&asarUnpack';
|
|
||||||
|
|
||||||
import type { MenuTemplate } from './menu';
|
import type { MenuTemplate } from './menu';
|
||||||
|
|
||||||
// Prevent tray being garbage collected
|
// Prevent tray being garbage collected
|
||||||
|
|||||||
@ -18,10 +18,13 @@ export type PluginLifecycleExtra<Config, Context, This> = This & {
|
|||||||
start?: PluginLifecycleSimple<Context, This>;
|
start?: PluginLifecycleSimple<Context, This>;
|
||||||
stop?: PluginLifecycleSimple<Context, This>;
|
stop?: PluginLifecycleSimple<Context, This>;
|
||||||
onConfigChange?: (this: This, newConfig: Config) => void | Promise<void>;
|
onConfigChange?: (this: This, newConfig: Config) => void | Promise<void>;
|
||||||
onPlayerApiReady?: (this: This, playerApi: YoutubePlayer) => void | Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
export type RendererPluginLifecycleExtra<Config, Context, This> = This & PluginLifecycleExtra<Config, Context, This> & {
|
||||||
|
onPlayerApiReady?: (this: This, playerApi: YoutubePlayer, context: Context) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export type PluginLifecycle<Config, Context, This> = PluginLifecycleSimple<Context, This> | PluginLifecycleExtra<Config, Context, This>;
|
export type PluginLifecycle<Config, Context, This> = PluginLifecycleSimple<Context, This> | PluginLifecycleExtra<Config, Context, This>;
|
||||||
|
export type RendererPluginLifecycle<Config, Context, This> = PluginLifecycleSimple<Context, This> | RendererPluginLifecycleExtra<Config, Context, This>;
|
||||||
|
|
||||||
export interface PluginDef<
|
export interface PluginDef<
|
||||||
BackendProperties,
|
BackendProperties,
|
||||||
@ -46,5 +49,5 @@ export interface PluginDef<
|
|||||||
} & PluginLifecycle<Config, PreloadContext<Config>, PreloadProperties>;
|
} & PluginLifecycle<Config, PreloadContext<Config>, PreloadProperties>;
|
||||||
renderer?: {
|
renderer?: {
|
||||||
[Key in keyof RendererProperties]: RendererProperties[Key]
|
[Key in keyof RendererProperties]: RendererProperties[Key]
|
||||||
} & PluginLifecycle<Config, RendererContext<Config>, RendererProperties>;
|
} & RendererPluginLifecycle<Config, RendererContext<Config>, RendererProperties>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
"@assets": ["./assets/*"]
|
"@assets/*": ["./assets/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": ["./dist"],
|
"exclude": ["./dist"],
|
||||||
|
|||||||
Reference in New Issue
Block a user