Merge pull request #1259 from organization/feat/fork-to-main

This commit is contained in:
JellyBrick
2023-10-07 22:46:43 +09:00
committed by GitHub
81 changed files with 3368 additions and 2739 deletions

View File

@ -1 +1,3 @@
.eslintrc.js
rollup.main.config.ts
rollup.preload.config.ts

View File

@ -40,21 +40,21 @@ jobs:
- name: Build and release on Mac
if: startsWith(matrix.os, 'macOS') && github.repository == 'th-ch/youtube-music'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run release:mac
- name: Build and release on Linux
if: startsWith(matrix.os, 'ubuntu') && github.repository == 'th-ch/youtube-music'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run release:linux
- name: Build and release on Windows
if: startsWith(matrix.os, 'windows') && github.repository == 'th-ch/youtube-music'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run release:win
@ -107,7 +107,7 @@ jobs:
uses: cardinalby/git-get-release-action@v1
id: get_draft_release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
latest: true
draft: true
@ -117,7 +117,7 @@ jobs:
if: ${{ env.VERSION_HASH == '' }}
uses: irongut/EditRelease@v1.2.0
with:
token: ${{ secrets.GH_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
id: ${{ steps.get_draft_release.outputs.id }}
draft: false
prerelease: false
@ -136,7 +136,7 @@ jobs:
- name: Commit changelog
if: ${{ env.VERSION_HASH == '' }}
uses: stefanzweifel/git-auto-commit-action@v4
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Update changelog for ${{ env.VERSION_TAG }}
file_pattern: "changelog.md"

View File

@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: "Dependency Review"
uses: actions/dependency-review-action@v3

3
assets/menu.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 17h12a1 1 0 0 1 .117 1.993L15 19H3a1 1 0 0 1-.117-1.993L3 17h12H3Zm0-6h18a1 1 0 0 1 .117 1.993L21 13H3a1 1 0 0 1-.117-1.993L3 11h18H3Zm0-6h15a1 1 0 0 1 .117 1.993L18 7H3a1 1 0 0 1-.117-1.993L3 5h15H3Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

6
assets/youtube-music.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 176 176" width="32" height="32">
<circle fill="red" cx="88" cy="88" r="88"/>
<path fill="#FFF"
d="M88 46c23.1 0 42 18.8 42 42s-18.8 42-42 42-42-18.8-42-42 18.9-42 42-42m0-4c-25.4 0-46 20.6-46 46s20.6 46 46 46 46-20.6 46-46-20.6-46-46-46z"/>
<path fill="#FFF" d="M72 111l39-24-39-22z"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

View File

@ -65,24 +65,107 @@ const defaultConfig = {
proxy: '',
startingPage: '',
overrideUserAgent: false,
themes: {} as string[],
themes: [] as string[],
},
/** please order alphabetically */
'plugins': {
// Enabled plugins
'navigation': {
enabled: true,
},
'adblocker': {
enabled: true,
cache: true,
blocker: 'With blocklists',
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
disableDefaultLists: [],
disableDefaultLists: false,
},
'album-color-theme': {},
'ambient-mode': {},
'audio-compressor': {},
'blur-nav-bar': {},
'bypass-age-restrictions': {},
'captions-selector': {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
},
'compact-sidebar': {},
'crossfade': {
enabled: false,
fadeInDuration: 1500, // Ms
fadeOutDuration: 5000, // Ms
secondsBeforeEnd: 10, // S
fadeScaling: 'linear', // 'linear', 'logarithmic' or a positive number in dB
},
'disable-autoplay': {
applyOnce: false,
},
'discord': {
enabled: false,
autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
activityTimoutEnabled: true, // If enabled, the discord rich presence gets cleared when music paused after the time specified below
activityTimoutTime: 10 * 60 * 1000, // 10 minutes
listenAlong: true, // Add a "listen along" button to rich presence
hideDurationLeft: false, // Hides the start and end time of the song to rich presence
},
'downloader': {
enabled: false,
ffmpegArgs: ['-b:a', '256k'], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
preset: 'mp3',
skipExisting: false,
playlistMaxItems: undefined as number | undefined,
},
'exponential-volume': {},
'in-app-menu': {},
'last-fm': {
enabled: false,
token: undefined as string | undefined, // Token used for authentication
session_key: undefined as string | undefined, // Session key used for scrobbling
api_root: 'http://ws.audioscrobbler.com/2.0/',
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
secret: 'a5d2a36fdf64819290f6982481eaffa2',
},
'lumiastream': {},
// Disabled plugins
'lyrics-genius': {
romanizedLyrics: false,
},
'navigation': {
enabled: true,
},
'no-google-login': {},
'notifications': {
enabled: false,
unpauseNotification: false,
urgency: 'normal', // Has effect only on Linux
// the following has effect only on Windows
interactive: true,
toastStyle: 1, // See plugins/notifications/utils for more info
refreshOnPlayPause: false,
trayControls: true,
hideButtonText: false,
},
'picture-in-picture': {
'enabled': false,
'alwaysOnTop': true,
'savePosition': true,
'saveSize': false,
'hotkey': 'P',
'pip-position': [10, 10],
'pip-size': [450, 275],
'isInPiP': false,
'useNativePiP': false,
},
'playback-speed': {},
'precise-volume': {
enabled: false,
steps: 1, // Percentage of volume to change
arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts
globalShortcuts: {
volumeUp: '',
volumeDown: '',
},
savedVolume: undefined as number | undefined, // Plugin save volume between session here
},
'quality-changer': {},
'shortcuts': {
enabled: false,
overrideMediaKeys: false,
@ -97,53 +180,8 @@ const defaultConfig = {
next: '',
} as Record<string, string>,
},
'downloader': {
enabled: false,
ffmpegArgs: ['-b:a', '256k'], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
preset: 'mp3',
skipExisting: false,
playlistMaxItems: undefined as number | undefined,
},
'last-fm': {
enabled: false,
token: undefined as string | undefined, // Token used for authentication
session_key: undefined as string | undefined, // Session key used for scrobbling
api_root: 'http://ws.audioscrobbler.com/2.0/',
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
secret: 'a5d2a36fdf64819290f6982481eaffa2',
},
'lyric-genius': {
romanizedLyrics: false,
},
'discord': {
enabled: false,
autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
activityTimoutEnabled: true, // If enabled, the discord rich presence gets cleared when music paused after the time specified below
activityTimoutTime: 10 * 60 * 1000, // 10 minutes
listenAlong: true, // Add a "listen along" button to rich presence
hideDurationLeft: false, // Hides the start and end time of the song to rich presence
},
'notifications': {
enabled: false,
unpauseNotification: false,
urgency: 'normal', // Has effect only on Linux
// the following has effect only on Windows
interactive: true,
toastStyle: 1, // See plugins/notifications/utils for more info
refreshOnPlayPause: false,
trayControls: true,
hideButtonText: false,
},
'precise-volume': {
enabled: false,
steps: 1, // Percentage of volume to change
arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts
globalShortcuts: {
volumeUp: '',
volumeDown: '',
},
savedVolume: undefined as number | undefined, // Plugin save volume between session here
'skip-silences': {
onlySkipBeginning: false,
},
'sponsorblock': {
enabled: false,
@ -157,6 +195,9 @@ const defaultConfig = {
'music_offtopic',
],
},
'taskbar-mediacontrol': {},
'touchbar': {},
'tuna-obs': {},
'video-toggle': {
enabled: false,
hideVideo: false,
@ -164,34 +205,6 @@ const defaultConfig = {
forceHide: false,
align: '',
},
'picture-in-picture': {
'enabled': false,
'alwaysOnTop': true,
'savePosition': true,
'saveSize': false,
'hotkey': 'P',
'pip-position': [10, 10],
'pip-size': [450, 275],
'isInPiP': false,
'useNativePiP': false,
},
'captions-selector': {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
disabledCaptions: false,
},
'skip-silences': {
onlySkipBeginning: false,
},
'crossfade': {
enabled: false,
fadeInDuration: 1500, // Ms
fadeOutDuration: 5000, // Ms
secondsBeforeEnd: 10, // S
fadeScaling: 'linear', // 'linear', 'logarithmic' or a positive number in dB
},
'visualizer': {
enabled: false,
type: 'butterchurn',

View File

@ -10,9 +10,9 @@ import { getOptions, setMenuOptions, setOptions } from './plugins';
import { sendToFront } from '../providers/app-controls';
import { Entries } from '../utils/type-utils';
type DefaultPluginsConfig = typeof defaultConfig.plugins;
type OneOfDefaultConfigKey = keyof DefaultPluginsConfig;
type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey];
export type DefaultPluginsConfig = typeof defaultConfig.plugins;
export type OneOfDefaultConfigKey = keyof DefaultPluginsConfig;
export type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};

134
index.ts
View File

@ -1,6 +1,6 @@
import path from 'node:path';
import electron, { BrowserWindow } from 'electron';
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
import enhanceWebRequest from 'electron-better-web-request';
import is from 'electron-is';
import unhandled from 'electron-unhandled';
@ -11,13 +11,40 @@ import { BetterWebRequest } from 'electron-better-web-request/lib/electron-bette
import config from './config';
import { setApplicationMenu } from './menu';
import { fileExists, injectCSS } from './plugins/utils';
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils';
import { isTesting } from './utils/testing';
import { setUpTray } from './tray';
import { setupSongInfo } from './providers/song-info';
import { restart, setupAppControls } from './providers/app-controls';
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler';
import adblocker from './plugins/adblocker/back';
import albumColorTheme from './plugins/album-color-theme/back';
import ambientMode from './plugins/ambient-mode/back';
import blurNavigationBar from './plugins/blur-nav-bar/back';
import captionsSelector from './plugins/captions-selector/back';
import crossfade from './plugins/crossfade/back';
import discord from './plugins/discord/back';
import downloader from './plugins/downloader/back';
import inAppMenu from './plugins/in-app-menu/back';
import lastFm from './plugins/last-fm/back';
import lumiaStream from './plugins/lumiastream/back';
import lyricsGenius from './plugins/lyrics-genius/back';
import navigation from './plugins/navigation/back';
import noGoogleLogin from './plugins/no-google-login/back';
import notifications from './plugins/notifications/back';
import pictureInPicture, { setOptions as pipSetOptions } from './plugins/picture-in-picture/back';
import preciseVolume from './plugins/precise-volume/back';
import qualityChanger from './plugins/quality-changer/back';
import shortcuts from './plugins/shortcuts/back';
import sponsorBlock from './plugins/sponsorblock/back';
import taskbarMediaControl from './plugins/taskbar-mediacontrol/back';
import touchbar from './plugins/touchbar/back';
import tunaObs from './plugins/tuna-obs/back';
import videoToggle from './plugins/video-toggle/back';
import visualizer from './plugins/visualizer/back';
import youtubeMusicCSS from './youtube-music.css';
// Catch errors and log them
unhandled({
@ -28,7 +55,6 @@ unhandled({
// Disable Node options if the env var is set
process.env.NODE_OPTIONS = '';
const { app } = electron;
// Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow | null;
autoUpdater.autoDownload = false;
@ -38,7 +64,9 @@ if (!gotTheLock) {
app.exit();
}
app.commandLine.appendSwitch('enable-features', 'SharedArrayBuffer'); // Required for downloader
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
// OverlayScrollbar: Required for overlay scrollbars
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer');
if (config.get('options.disableHardwareAcceleration')) {
if (is.dev()) {
console.log('Disabling hardware acceleration');
@ -74,8 +102,50 @@ function onClosed() {
mainWindow = null;
}
const mainPlugins = {
'adblocker': adblocker,
'album-color-theme': albumColorTheme,
'ambient-mode': ambientMode,
'blur-nav-bar': blurNavigationBar,
'captions-selector': captionsSelector,
'crossfade': crossfade,
'discord': discord,
'downloader': downloader,
'in-app-menu': inAppMenu,
'last-fm': lastFm,
'lumiastream': lumiaStream,
'lyrics-genius': lyricsGenius,
'navigation': navigation,
'no-google-login': noGoogleLogin,
'notifications': notifications,
'picture-in-picture': pictureInPicture,
'precise-volume': preciseVolume,
'quality-changer': qualityChanger,
'shortcuts': shortcuts,
'sponsorblock': sponsorBlock,
'taskbar-mediacontrol': undefined as typeof taskbarMediaControl | undefined,
'touchbar': undefined as typeof touchbar | undefined,
'tuna-obs': tunaObs,
'video-toggle': videoToggle,
'visualizer': visualizer,
};
export const mainPluginNames = Object.keys(mainPlugins);
if (is.windows()) {
mainPlugins['taskbar-mediacontrol'] = taskbarMediaControl;
delete mainPlugins['touchbar'];
} else if (is.macOS()) {
mainPlugins['touchbar'] = touchbar;
delete mainPlugins['taskbar-mediacontrol'];
} else {
delete mainPlugins['touchbar'];
delete mainPlugins['taskbar-mediacontrol'];
}
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
function loadPlugins(win: BrowserWindow) {
injectCSS(win.webContents, path.join(__dirname, 'youtube-music.css'));
injectCSS(win.webContents, youtubeMusicCSS);
// Load user CSS
const themes: string[] = config.get('options.themes');
if (Array.isArray(themes)) {
@ -83,7 +153,7 @@ function loadPlugins(win: BrowserWindow) {
fileExists(
cssFile,
() => {
injectCSS(win.webContents, cssFile);
injectCSSAsFile(win.webContents, cssFile);
},
() => {
console.warn(`CSS file "${cssFile}" does not exist, ignoring`);
@ -100,13 +170,17 @@ function loadPlugins(win: BrowserWindow) {
});
for (const [plugin, options] of config.plugins.getEnabled()) {
try {
if (Object.hasOwn(mainPlugins, plugin)) {
console.log('Loaded plugin - ' + plugin);
const pluginPath = path.join(__dirname, 'plugins', plugin, 'back.js');
fileExists(pluginPath, () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-member-access
const handle = require(pluginPath).default as (window: BrowserWindow, option: typeof options) => void;
handle(win, options);
});
const handler = mainPlugins[plugin as keyof typeof mainPlugins];
if (handler) {
handler(win, options as never);
}
}
} catch (e) {
console.error(`Failed to load plugin "${plugin}"`, e);
}
}
}
@ -137,6 +211,11 @@ function createMainWindow() {
}),
},
frame: !is.macOS() && !useInlineMenu,
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 36,
},
titleBarStyle: useInlineMenu
? 'hidden'
: (is.macOS()
@ -150,7 +229,7 @@ function createMainWindow() {
const { x, y } = windowPosition;
const winSize = win.getSize();
const displaySize
= electron.screen.getDisplayNearestPoint(windowPosition).bounds;
= screen.getDisplayNearestPoint(windowPosition).bounds;
if (
x + winSize[0] < displaySize.x - 8
|| x - winSize[0] > displaySize.x + displaySize.width
@ -182,7 +261,7 @@ function createMainWindow() {
win.webContents.loadURL(urlToLoad);
win.on('closed', onClosed);
const scaleFactor = electron.screen.getAllDisplays().length > 1 ? electron.screen.getPrimaryDisplay().scaleFactor : 1;
const scaleFactor = screen.getAllDisplays().length > 1 ? screen.getPrimaryDisplay().scaleFactor : 1;
const size = config.get('window-size');
const position = config.get('window-position');
@ -205,8 +284,7 @@ function createMainWindow() {
type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture'];
const setPiPOptions = config.plugins.isEnabled('picture-in-picture')
// eslint-disable-next-line @typescript-eslint/no-var-requires
? (key: string, value: unknown) => (require('./plugins/picture-in-picture/back') as typeof import('./plugins/picture-in-picture/back'))
.setOptions({ [key]: value })
? (key: string, value: unknown) => pipSetOptions({ [key]: value })
: () => {};
win.on('move', () => {
@ -353,7 +431,7 @@ app.on('window-all-closed', () => {
}
// Unregister all shortcuts.
electron.globalShortcut.unregisterAll();
globalShortcut.unregisterAll();
});
app.on('activate', () => {
@ -374,7 +452,7 @@ app.on('ready', () => {
console.log('Clearing app cache.');
}
electron.session.defaultSession.clearCache();
session.defaultSession.clearCache();
clearTimeout(clearCacheTimeout);
}, 20_000);
}
@ -389,7 +467,7 @@ app.on('ready', () => {
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) {
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk');
try { // Check if shortcut is registered and valid
const shortcutDetails = electron.shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
if (
shortcutDetails.target !== appLocation
|| shortcutDetails.appUserModelId !== appID
@ -397,7 +475,7 @@ app.on('ready', () => {
throw 'needUpdate';
}
} catch (error) { // If not valid -> Register shortcut
electron.shell.writeShortcutLink(
shell.writeShortcutLink(
shortcutPath,
error === 'needUpdate' ? 'update' : 'create',
{
@ -466,11 +544,11 @@ app.on('ready', () => {
message: 'A new version is available',
detail: `A new version is available and can be downloaded at ${downloadLink}`,
};
electron.dialog.showMessageBox(dialogOptions).then((dialogOutput) => {
dialog.showMessageBox(dialogOptions).then((dialogOutput) => {
switch (dialogOutput.response) {
// Download
case 1: {
electron.shell.openExternal(downloadLink);
shell.openExternal(downloadLink);
break;
}
@ -489,7 +567,7 @@ app.on('ready', () => {
}
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
electron.dialog.showMessageBox(mainWindow, {
dialog.showMessageBox(mainWindow, {
type: 'info', title: 'Hide Menu Enabled',
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
});
@ -522,7 +600,7 @@ function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProc
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
}
electron.dialog.showMessageBox(win, {
dialog.showMessageBox(win, {
type: 'error',
title: 'Window Unresponsive',
message: 'The Application is Unresponsive',
@ -547,15 +625,15 @@ function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProc
// HACK: electron-better-web-request's typing is wrong
type BetterSession = Omit<Electron.Session, 'webRequest'> & { webRequest: BetterWebRequest & Electron.WebRequest };
function removeContentSecurityPolicy(
session: BetterSession = electron.session.defaultSession as BetterSession,
betterSession: BetterSession = session.defaultSession as BetterSession,
) {
// Allows defining multiple "onHeadersReceived" listeners
// by enhancing the session.
// Some plugins (e.g. adblocker) also define a "onHeadersReceived" listener
enhanceWebRequest(session);
enhanceWebRequest(betterSession);
// Custom listener to tweak the content security policy
session.webRequest.onHeadersReceived((details, callback) => {
betterSession.webRequest.onHeadersReceived((details, callback) => {
details.responseHeaders ??= {};
// Remove the content security policy
@ -567,7 +645,7 @@ function removeContentSecurityPolicy(
type ResolverListener = { apply: () => Promise<Record<string, unknown>>; context: unknown };
// When multiple listeners are defined, apply them all
session.webRequest.setResolver('onHeadersReceived', async (listeners: ResolverListener[]) => {
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners: ResolverListener[]) => {
return listeners.reduce<Promise<Record<string, unknown>>>(
async (accumulator: Promise<Record<string, unknown>>, listener: ResolverListener) => {
const acc = await accumulator;

91
menu.ts
View File

@ -1,23 +1,50 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
import prompt from 'custom-electron-prompt';
import { restart } from './providers/app-controls';
import { getAllPlugins } from './plugins/utils';
import config from './config';
import { startingPages } from './providers/extracted-data';
import promptOptions from './providers/prompt-options';
export type MenuTemplate = (Electron.MenuItemConstructorOptions | Electron.MenuItem)[];
import adblockerMenu from './plugins/adblocker/menu';
import captionsSelectorMenu from './plugins/captions-selector/menu';
import crossfadeMenu from './plugins/crossfade/menu';
import disableAutoplayMenu from './plugins/disable-autoplay/menu';
import discordMenu from './plugins/discord/menu';
import downloaderMenu from './plugins/downloader/menu';
import lyricsGeniusMenu from './plugins/lyrics-genius/menu';
import notificationsMenu from './plugins/notifications/menu';
import pictureInPictureMenu from './plugins/picture-in-picture/menu';
import preciseVolumeMenu from './plugins/precise-volume/menu';
import shortcutsMenu from './plugins/shortcuts/menu';
import videoToggleMenu from './plugins/video-toggle/menu';
import visualizerMenu from './plugins/visualizer/menu';
import { getAvailablePluginNames } from './plugins/utils';
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
// True only if in-app-menu was loaded on launch
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
const betaPlugins = ['crossfade', 'lumiastream'];
const pluginMenus = {
'adblocker': adblockerMenu,
'disable-autoplay': disableAutoplayMenu,
'captions-selector': captionsSelectorMenu,
'crossfade': crossfadeMenu,
'discord': discordMenu,
'downloader': downloaderMenu,
'lyrics-genius': lyricsGeniusMenu,
'notifications': notificationsMenu,
'picture-in-picture': pictureInPictureMenu,
'precise-volume': preciseVolumeMenu,
'shortcuts': shortcutsMenu,
'video-toggle': videoToggleMenu,
'visualizer': visualizerMenu,
};
const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
label: label || plugin,
type: 'checkbox',
@ -47,33 +74,30 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
{
label: 'Plugins',
submenu:
getAllPlugins().map((plugin) => {
let pluginLabel = plugin;
if (betaPlugins.includes(plugin)) {
getAvailablePluginNames().map((pluginName) => {
let pluginLabel = pluginName;
if (betaPlugins.includes(pluginLabel)) {
pluginLabel += ' [beta]';
}
const pluginPath = path.join(__dirname, 'plugins', plugin, 'menu.js');
if (existsSync(pluginPath)) {
if (!config.plugins.isEnabled(plugin)) {
return pluginEnabledMenu(plugin, pluginLabel, true, refreshMenu);
if (Object.hasOwn(pluginMenus, pluginName)) {
const getPluginMenu = pluginMenus[pluginName as keyof typeof pluginMenus];
if (!config.plugins.isEnabled(pluginName)) {
return pluginEnabledMenu(pluginName, pluginLabel, true, refreshMenu);
}
type PluginType = (window: BrowserWindow, plugins: string, func: () => void) => Electron.MenuItemConstructorOptions[];
// eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-member-access
const getPluginMenu = require(pluginPath).default as PluginType;
return {
label: pluginLabel,
submenu: [
pluginEnabledMenu(plugin, 'Enabled', true, refreshMenu),
pluginEnabledMenu(pluginName, 'Enabled', true, refreshMenu),
{ type: 'separator' },
...getPluginMenu(win, config.plugins.getOptions(plugin), refreshMenu),
...getPluginMenu(win, config.plugins.getOptions(pluginName), refreshMenu),
],
} satisfies Electron.MenuItemConstructorOptions;
}
return pluginEnabledMenu(plugin, pluginLabel);
return pluginEnabledMenu(pluginName, pluginLabel);
}),
},
{
@ -97,14 +121,25 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
},
{
label: 'Starting page',
submenu: Object.keys(startingPages).map((name) => ({
submenu: (() => {
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({
label: name,
type: 'radio',
checked: config.get('options.startingPage') === name,
click() {
config.set('options.startingPage', name);
},
})),
}));
subMenuArray.unshift({
label: 'Unset',
type: 'radio',
checked: config.get('options.startingPage') === '',
click() {
config.set('options.startingPage', '');
},
});
return subMenuArray;
})(),
},
{
label: 'Visual Tweaks',
@ -152,7 +187,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
{
label: 'No theme',
type: 'radio',
checked: !config.get('options.themes'), // Todo rename "themes"
checked: config.get('options.themes')?.length === 0, // Todo rename "themes"
click() {
config.set('options.themes', []);
},
@ -160,8 +195,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
{ type: 'separator' },
{
label: 'Import custom CSS file',
type: 'radio',
checked: false,
type: 'normal',
async click() {
const { filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'CSS Files', extensions: ['css'] }],
@ -275,11 +309,10 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
label: 'Advanced options',
submenu: [
{
label: 'Proxy',
type: 'checkbox',
checked: !!(config.get('options.proxy')),
click(item) {
setProxy(item, win);
label: 'Set Proxy',
type: 'normal',
async click(item) {
await setProxy(item, win);
},
},
{

3038
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,17 +14,36 @@
"build": {
"appId": "com.github.th-ch.youtube-music",
"productName": "YouTube Music",
"mac": {
"identity": null,
"files": [
"!*",
"dist",
"!dist/plugins/taskbar-mediacontrol${/*}",
"license",
"node_modules",
"package.json",
"tests"
"!node_modules",
"node_modules/custom-electron-prompt/**",
"node_modules/youtubei.js/**",
"node_modules/undici/**",
"node_modules/@fastify/busboy/**",
"node_modules/jintr/**",
"node_modules/acorn/**",
"node_modules/tslib/**",
"node_modules/semver/**",
"node_modules/lru-cache/**",
"node_modules/detect-libc/**",
"node_modules/color/**",
"node_modules/color-convert/**",
"node_modules/color-string/**",
"node_modules/color-name/**",
"node_modules/simple-swizzle/**",
"node_modules/is-arrayish/**",
"node_modules/@cliqz/adblocker-electron-preload/**",
"node_modules/@cliqz/adblocker-content/**",
"node_modules/@cliqz/adblocker-extended-selectors/**",
"node_modules/@ffmpeg.wasm/core-mt/**",
"!node_modules/**/*.map",
"!node_modules/**/*.ts"
],
"mac": {
"identity": null,
"target": [
{
"target": "dmg",
@ -38,15 +57,6 @@
},
"win": {
"icon": "assets/generated/icons/win/icon.ico",
"files": [
"!*",
"dist",
"!dist/plugins/touchbar${/*}",
"license",
"node_modules",
"package.json",
"tests"
],
"target": [
{
"target": "nsis",
@ -69,15 +79,6 @@
},
"linux": {
"icon": "assets/generated/icons/png",
"files": [
"!*",
"dist",
"!dist/plugins/{touchbar,taskbar-mediacontrol}${/*}",
"license",
"node_modules",
"package.json",
"tests"
],
"category": "AudioVideo",
"target": [
"AppImage",
@ -101,31 +102,29 @@
}
},
"scripts": {
"test": "playwright test",
"test:debug": "DEBUG=pw:browser* playwright test",
"start": "npm run tsc-and-copy && electron ./dist/index.js",
"start:debug": "ELECTRON_ENABLE_LOGGING=1 electron ./dist/index.js",
"test": "npm run build && playwright test",
"test:debug": "DEBUG=pw:browser* npm run build && playwright test",
"rollup:preload": "rollup -c rollup.preload.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs",
"rollup:main": "rollup -c rollup.main.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs",
"build": "npm run rollup:preload && npm run rollup:main",
"start": "npm run build && electron ./dist/index.js",
"start:debug": "ELECTRON_ENABLE_LOGGING=1 npm run start",
"generate:package": "node utils/generate-package-json.js",
"postinstall": "npm run plugins",
"postinstall": "npm run plugins && npm run clean",
"clean": "del-cli dist && del-cli pack",
"ytm-resource-copy-files": "copyfiles error.html youtube-music.css assets/**/* dist/",
"copy-files": "copyfiles -u 1 plugins/**/*.html plugins/**/*.css plugins/**/*.bin plugins/**/*.js dist/plugins/",
"tsc-and-copy": "tsc && npm run plugin:adblocker-without-tsc && npm run ytm-resource-copy-files && npm run copy-files",
"build": "npm run clean && npm run tsc-and-copy && electron-builder --win --mac --linux -p never",
"build:linux": "npm run clean && npm run tsc-and-copy && electron-builder --linux -p never",
"build:mac": "npm run clean && npm run tsc-and-copy && electron-builder --mac dmg:x64 -p never",
"build:mac:arm64": "npm run clean && npm run tsc-and-copy && electron-builder --mac dmg:arm64 -p never",
"build:win": "npm run clean && npm run tsc-and-copy && electron-builder --win -p never",
"build:win:x64": "npm run clean && npm run tsc-and-copy && electron-builder --win nsis:x64 -p never",
"dist": "npm run clean && npm run build && electron-builder --win --mac --linux -p never",
"dist:linux": "npm run clean && npm run build && electron-builder --linux -p never",
"dist:mac": "npm run clean && npm run build && electron-builder --mac dmg:x64 -p never",
"dist:mac:arm64": "npm run clean && npm run build && electron-builder --mac dmg:arm64 -p never",
"dist:win": "npm run clean && npm run build && electron-builder --win -p never",
"dist:win:x64": "npm run clean && npm run build && electron-builder --win nsis:x64 -p never",
"lint": "eslint .",
"changelog": "auto-changelog",
"plugins": "npm run plugin:adblocker && npm run plugin:bypass-age-restrictions",
"plugin:adblocker-without-tsc": "del-cli plugins/adblocker/ad-blocker-engine.bin && node dist/plugins/adblocker/blocker.js",
"plugin:adblocker": "del-cli plugins/adblocker/ad-blocker-engine.bin && tsc && node dist/plugins/adblocker/blocker.js",
"plugins": "npm run plugin:bypass-age-restrictions",
"plugin:bypass-age-restrictions": "del-cli node_modules/simple-youtube-age-restriction-bypass/package.json && npm run generate:package simple-youtube-age-restriction-bypass",
"release:linux": "npm run clean && npm run tsc-and-copy && electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "npm run clean && npm run tsc-and-copy && electron-builder --mac -p always",
"release:win": "npm run clean && npm run tsc-and-copy && electron-builder --win -p always",
"release:linux": "npm run clean && npm run build && electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "npm run clean && npm run build && electron-builder --mac -p always",
"release:win": "npm run clean && npm run build && electron-builder --win -p always",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"engines": {
@ -142,7 +141,6 @@
"butterchurn-presets": "2.4.7",
"conf": "10.2.0",
"custom-electron-prompt": "1.5.7",
"custom-electron-titlebar": "4.1.6",
"electron-better-web-request": "1.0.1",
"electron-debug": "3.2.0",
"electron-is": "3.0.0",
@ -150,8 +148,8 @@
"electron-store": "8.1.0",
"electron-unhandled": "4.0.1",
"electron-updater": "6.1.4",
"fast-average-color-node": "^2.6.0",
"filenamify": "4.3.0",
"fast-average-color": "9.4.0",
"filenamify": "6.0.0",
"howler": "2.2.4",
"html-to-text": "9.0.5",
"keyboardevent-from-electron-accelerator": "2.0.0",
@ -160,33 +158,47 @@
"node-id3": "0.2.6",
"simple-youtube-age-restriction-bypass": "git+https://github.com/MiepHD/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.5",
"vudio": "2.1.1",
"x11": "2.3.0",
"youtubei.js": "6.4.1",
"ytpl": "2.3.0"
},
"overrides": {
"rollup": "4.0.2",
"node-gyp": "9.4.0",
"xml2js": "0.6.2",
"dbus-next": "0.10.2",
"node-fetch": "2.7.0",
"@electron/universal": "1.4.2",
"electron": "27.0.0-beta.9"
},
"devDependencies": {
"@playwright/test": "1.38.1",
"@rollup/plugin-commonjs": "25.0.5",
"@rollup/plugin-image": "3.0.3",
"@rollup/plugin-json": "6.0.1",
"@rollup/plugin-node-resolve": "15.2.2",
"@rollup/plugin-terser": "0.4.4",
"@rollup/plugin-typescript": "11.1.5",
"@rollup/plugin-wasm": "6.2.2",
"@total-typescript/ts-reset": "0.5.1",
"@types/electron-localshortcut": "3.1.1",
"@types/howler": "2.2.9",
"@types/html-to-text": "9.0.2",
"@typescript-eslint/eslint-plugin": "6.7.4",
"auto-changelog": "2.4.0",
"copyfiles": "2.4.1",
"del-cli": "5.1.0",
"electron": "27.0.0-beta.9",
"electron-builder": "24.6.4",
"electron-devtools-installer": "3.2.0",
"eslint": "8.50.0",
"eslint": "8.51.0",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-prettier": "5.0.0",
"node-gyp": "9.4.0",
"playwright": "1.38.1",
"rollup": "4.0.2",
"rollup-plugin-copy": "3.5.0",
"rollup-plugin-import-css": "3.3.4",
"rollup-plugin-string": "3.0.0",
"typescript": "5.2.2"
},
"auto-changelog": {

View File

@ -1,8 +1,9 @@
// Used for caching
import path from 'node:path';
import { promises } from 'node:fs';
import fs, { promises } from 'node:fs';
import { ElectronBlocker } from '@cliqz/adblocker-electron';
import { app } from 'electron';
const SOURCES = [
'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt',
@ -20,19 +21,31 @@ export const loadAdBlockerEngine = (
session: Electron.Session | undefined = undefined,
cache = true,
additionalBlockLists = [],
disableDefaultLists: boolean | string[] = false,
disableDefaultLists: boolean | unknown[] = false,
) => {
// Only use cache if no additional blocklists are passed
let cacheDirectory: string;
if (app.isPackaged) {
cacheDirectory = path.join(app.getPath('userData'), 'adblock_cache');
} else {
cacheDirectory = path.resolve(__dirname, 'adblock_cache');
}
if (!fs.existsSync(cacheDirectory)) {
fs.mkdirSync(cacheDirectory);
}
const cachingOptions
= cache && additionalBlockLists.length === 0
? {
path: path.resolve(__dirname, 'ad-blocker-engine.bin'),
path: path.join(cacheDirectory, 'adblocker-engine.bin'),
read: promises.readFile,
write: promises.writeFile,
}
: undefined;
const lists = [
...(disableDefaultLists ? [] : SOURCES),
...(
(disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) ? [] : SOURCES
),
...additionalBlockLists,
];
@ -58,6 +71,3 @@ export const loadAdBlockerEngine = (
};
export default { loadAdBlockerEngine };
if (require.main === module) {
loadAdBlockerEngine(); // Generate the engine without enabling it
}

View File

@ -0,0 +1,4 @@
export default () => {
const path = '@cliqz/adblocker-electron-preload'; // prevent require hoisting
require(path);
};

3
plugins/adblocker/inject.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
const inject: () => void;
export default inject;

View File

@ -7,7 +7,7 @@
Parts of this code is derived from set-constant.js:
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
*/
module.exports = () => {
{
const pruner = function (o) {
delete o.playerAds;
@ -432,3 +432,4 @@
//
trapChain(window, chain);
})();
};

View File

@ -1,6 +1,8 @@
import config, { blockers } from './config';
export default () => {
import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => {
return [
{
label: 'Blocker',

View File

@ -1,11 +1,13 @@
import config from './config';
import config, { blockers } from './config';
import inject from './inject';
import injectCliqzPreload from './inject-cliqz-preload';
export default async () => {
if (await config.shouldUseBlocklists()) {
// Preload adblocker to inject scripts/styles
require('@cliqz/adblocker-electron-preload');
injectCliqzPreload();
// eslint-disable-next-line @typescript-eslint/await-thenable
} else if ((await config.get('blocker')) === config.blockers.InPlayer) {
require('./inject.js');
} else if ((await config.get('blocker')) === blockers.InPlayer) {
inject();
}
};

View File

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

View File

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

View File

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

View File

@ -5,7 +5,9 @@ import { ipcRenderer } from 'electron';
import configProvider from './config';
import { ElementFromFile, templatePath } from '../utils';
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html';
import { ElementFromHtml } from '../utils';
import { YoutubePlayer } from '../../types/youtube-player';
import type { ConfigType } from '../../config/dynamic';
@ -27,9 +29,7 @@ let config: ConfigType<'captions-selector'>;
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
const captionsSettingsButton = ElementFromFile(
templatePath(__dirname, 'captions-settings-template.html'),
);
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
export default async () => {
// RENDERER

View File

@ -14,7 +14,7 @@ export default (): MenuTemplate => [
{
label: 'No captions by default',
type: 'checkbox',
checked: config.get('disabledCaptions'),
checked: config.get('disableCaptions'),
click(item) {
config.set('disableCaptions', item.checked);
},

View File

@ -7,11 +7,13 @@ import config from './config';
import promptOptions from '../../providers/prompt-options';
import configOptions from '../../config/defaults';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
const defaultOptions = configOptions.plugins.crossfade;
export default (win: BrowserWindow) => [
export default (win: BrowserWindow): MenuTemplate => [
{
label: 'Advanced',
async click() {

View File

@ -1,4 +1,6 @@
export default () => {
import type { ConfigType } from '../../config/dynamic';
export default (options: ConfigType<'disable-autoplay'>) => {
const timeUpdateListener = (e: Event) => {
if (e.target instanceof HTMLVideoElement) {
e.target.pause();
@ -6,13 +8,16 @@ export default () => {
};
document.addEventListener('apiLoaded', (apiEvent) => {
apiEvent.detail.addEventListener('videodatachange', (name: string) => {
const eventListener = (name: string) => {
if (options.applyOnce) {
apiEvent.detail.removeEventListener('videodatachange', eventListener);
}
if (name === 'dataloaded') {
apiEvent.detail.pauseVideo();
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener);
} else {
document.querySelector<HTMLVideoElement>('video')?.removeEventListener('timeupdate', timeUpdateListener);
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
}
});
};
apiEvent.detail.addEventListener('videodatachange', eventListener);
}, { once: true, passive: true });
};

View File

@ -0,0 +1,20 @@
import { BrowserWindow } from 'electron';
import { setMenuOptions } from '../../config/plugins';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
export default (_: BrowserWindow, options: ConfigType<'disable-autoplay'>): MenuTemplate => [
{
label: 'Applies only on startup',
type: 'checkbox',
checked: options.applyOnce,
click() {
setMenuOptions('disable-autoplay', {
applyOnce: !options.applyOnce,
});
}
}
];

View File

@ -1,12 +1,11 @@
import prompt from 'custom-electron-prompt';
import { Electron } from 'playwright';
import { clear, connect, isConnected, registerRefresh } from './back';
import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options';
import { singleton } from '../../providers/decorators';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
@ -16,14 +15,14 @@ const registerRefreshOnce = singleton((refreshMenu: () => void) => {
type DiscordOptions = ConfigType<'discord'>;
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void) => {
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => {
registerRefreshOnce(refreshMenu);
return [
{
label: isConnected() ? 'Connected' : 'Reconnect',
enabled: !isConnected(),
click: connect,
click: () => connect(),
},
{
label: 'Auto reconnect',

View File

@ -2,7 +2,7 @@ import { createWriteStream, existsSync, mkdirSync, writeFileSync, } from 'node:f
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
import { ClientType, Innertube, UniversalCache, Utils } from 'youtubei.js';
import is from 'electron-is';
import ytpl from 'ytpl';
@ -24,6 +24,8 @@ import { cropMaxWidth, getFolder, presets, sendFeedback as sendFeedback_, setBad
import config from './config';
import style from './style.css';
import { fetchFromGenius } from '../lyrics-genius/back';
import { isEnabled } from '../../config/plugins';
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
@ -32,6 +34,7 @@ import { cache } from '../../providers/decorators';
import type { GetPlayerResponse } from '../../types/get-player-response';
type CustomSongInfo = SongInfo & { trackId?: string };
const ffmpeg = createFFmpeg({
@ -68,7 +71,7 @@ const sendError = (error: Error, source?: string) => {
export default async (win_: BrowserWindow) => {
win = win_;
injectCSS(win.webContents, join(__dirname, 'style.css'));
injectCSS(win.webContents, style);
const cookie = (await win.webContents.session.cookies.get({ url: 'https://music.youtube.com' })).map((it) =>
it.name + '=' + it.value + ';'
@ -77,6 +80,24 @@ export default async (win_: BrowserWindow) => {
cache: new UniversalCache(false),
cookie,
generate_session_locally: true,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string' ?
new URL(input) :
input instanceof URL ?
input : new URL(input.url);
if (init?.body && !init.method) {
init.method = 'POST';
}
const request = new Request(
url,
input instanceof Request ? input : undefined,
);
return net.fetch(request, init);
}
});
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
@ -113,8 +134,7 @@ async function downloadSongUnsafe(
setName: (name: string) => void,
playlistFolder: string | undefined = undefined,
trackId: string | undefined = undefined,
increasePlaylistProgress: (value: number) => void = () => {
},
increasePlaylistProgress: (value: number) => void = () => {},
) {
const sendFeedback = (message: unknown, progress?: number) => {
if (!playlistFolder) {
@ -540,11 +560,7 @@ const getPlaylistID = (aURL: URL) => {
};
const getVideoId = (url: URL | string): string | null => {
if (typeof url === 'string') {
url = new URL(url);
}
return url.searchParams.get('v');
return (new URL(url)).searchParams.get('v');
};
const getMetadata = (info: TrackInfo): CustomSongInfo => ({

View File

@ -1,15 +1,15 @@
import { ipcRenderer } from 'electron';
import downloadHTML from './templates/download.html';
import defaultConfig from '../../config/defaults';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromFile, templatePath } from '../utils';
import { ElementFromHtml } from '../utils';
import { getSongInfo } from '../../providers/song-info-front';
let menu: Element | null = null;
let progress: Element | null = null;
const downloadButton = ElementFromFile(
templatePath(__dirname, 'download.html'),
);
const downloadButton = ElementFromHtml(downloadHTML);
let doneFirstLoad = false;

View File

@ -1,27 +1,60 @@
import path from 'node:path';
import { register } from 'electron-localshortcut';
// eslint-disable-next-line import/no-unresolved
import { attachTitlebarToWindow, setupTitlebar } from 'custom-electron-titlebar/main';
import { BrowserWindow } from 'electron';
import { BrowserWindow, Menu, MenuItem, ipcMain } from 'electron';
import titlebarStyle from './titlebar.css';
import { injectCSS } from '../utils';
setupTitlebar();
// Tracks menu visibility
export default (win: BrowserWindow) => {
// Css for custom scrollbar + disable drag area(was causing bugs)
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
injectCSS(win.webContents, titlebarStyle);
win.once('ready-to-show', () => {
attachTitlebarToWindow(win);
register(win, '`', () => {
win.webContents.send('toggleMenu');
});
});
ipcMain.handle(
'get-menu',
() => JSON.parse(JSON.stringify(
Menu.getApplicationMenu(),
(key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined),
),
);
const getMenuItemById = (commandId: number): MenuItem | null => {
const menu = Menu.getApplicationMenu();
let target: MenuItem | null = null;
const stack = [...menu?.items ?? []];
while (stack.length > 0) {
const now = stack.shift();
now?.submenu?.items.forEach((item) => stack.push(item));
if (now?.commandId === commandId) {
target = now;
break;
}
}
return target;
};
ipcMain.handle('menu-event', (event, commandId: number) => {
const target = getMenuItemById(commandId);
if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
});
ipcMain.handle('get-menu-by-id', (_, commandId: number) => {
const result = getMenuItemById(commandId);
return JSON.parse(JSON.stringify(
result,
(key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined),
);
});
};

View File

@ -1,9 +0,0 @@
declare module 'custom-electron-titlebar' {
// eslint-disable-next-line import/no-unresolved
import OriginalTitlebar from 'custom-electron-titlebar/dist/titlebar';
// eslint-disable-next-line import/no-unresolved
import { Color as OriginalColor } from 'custom-electron-titlebar/dist/vs/base/common/color';
export const Color: typeof OriginalColor;
export const Titlebar: typeof OriginalTitlebar;
}

View File

@ -1,104 +1,95 @@
import { ipcRenderer, Menu } from 'electron';
// eslint-disable-next-line import/no-unresolved
import { Color, Titlebar } from 'custom-electron-titlebar';
import config from '../../config';
import { createPanel } from './menu/panel';
import logo from '../../assets/menu.svg';
import { isEnabled } from '../../config/plugins';
type ElectronCSSStyleDeclaration = CSSStyleDeclaration & { webkitAppRegion: 'drag' | 'no-drag' };
type ElectronHTMLElement = HTMLElement & { style: ElectronCSSStyleDeclaration };
import config from '../../config';
function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector);
}
const isMacOS = navigator.userAgent.includes('Macintosh');
export default () => {
const visible = () => !!($('.cet-menubar')?.firstChild);
const bar = new Titlebar({
icon: 'https://cdn-icons-png.flaticon.com/512/5358/5358672.png',
backgroundColor: Color.fromHex('#050505'),
itemBackgroundColor: Color.fromHex('#1d1d1d') ,
svgColor: Color.WHITE,
menu: config.get('options.hideMenu') ? null as unknown as Menu : undefined,
let hideMenu = config.get('options.hideMenu');
const titleBar = document.createElement('title-bar');
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
if (isMacOS) titleBar.style.setProperty('--offset-left', '70px');
logo.classList.add('title-bar-icon');
const logoClick = () => {
hideMenu = !hideMenu;
let visibilityStyle: string;
if (hideMenu) {
visibilityStyle = 'hidden';
} else {
visibilityStyle = 'visible';
}
const menus = document.querySelectorAll<HTMLElement>('menu-button');
menus.forEach((menu) => {
menu.style.visibility = visibilityStyle;
});
bar.updateTitle(' ');
};
logo.onclick = logoClick;
ipcRenderer.on('toggleMenu', logoClick);
if (!isMacOS) titleBar.appendChild(logo);
document.body.appendChild(titleBar);
if (navBar) {
const observer = new MutationObserver((mutations) => {
mutations.forEach(() => {
titleBar.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor);
document.querySelector('html')!.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor);
});
});
observer.observe(navBar, { attributes : true, attributeFilter : ['style'] });
}
const updateMenu = async () => {
const children = [...titleBar.children];
children.forEach((child) => {
if (child !== logo) child.remove();
});
const menu = await ipcRenderer.invoke('get-menu') as Menu | null;
if (!menu) return;
menu.items.forEach((menuItem) => {
const menu = document.createElement('menu-button');
createPanel(titleBar, menu, menuItem.submenu?.items ?? []);
menu.append(menuItem.label);
titleBar.appendChild(menu);
if (hideMenu) {
menu.style.visibility = 'hidden';
}
});
};
updateMenu();
document.title = 'Youtube Music';
const toggleMenu = () => {
if (visible()) {
bar.updateMenu(null as unknown as Menu);
} else {
bar.refreshMenu();
}
};
$('.cet-window-icon')?.addEventListener('click', toggleMenu);
ipcRenderer.on('toggleMenu', toggleMenu);
ipcRenderer.on('refreshMenu', () => {
if (visible()) {
bar.refreshMenu();
}
updateMenu();
});
if (isEnabled('picture-in-picture')) {
ipcRenderer.on('pip-toggle', () => {
bar.refreshMenu();
updateMenu();
});
}
// Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it)
document.addEventListener('apiLoaded', () => {
setNavbarMargin();
const playPageObserver = new MutationObserver(setNavbarMargin);
const appLayout = $('ytmusic-app-layout');
if (appLayout) {
playPageObserver.observe(appLayout, { attributeFilter: ['player-page-open_', 'playerPageOpen_'] });
const htmlHeadStyle = $('head > div > style');
if (htmlHeadStyle) {
// HACK: This is a hack to remove the scrollbar width
htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {');
}
setupSearchOpenObserver();
setupMenuOpenObserver();
}, { once: true, passive: true });
};
function setupSearchOpenObserver() {
const searchOpenObserver = new MutationObserver((mutations) => {
const navBarBackground = $<ElectronHTMLElement>('#nav-bar-background');
if (navBarBackground) {
navBarBackground.style.webkitAppRegion = (mutations[0].target as HTMLElement & { opened: boolean }).opened ? 'no-drag' : 'drag';
}
});
const searchBox = $('ytmusic-search-box');
if (searchBox) {
searchOpenObserver.observe(searchBox, { attributeFilter: ['opened'] });
}
}
function setupMenuOpenObserver() {
const cetMenubar = $('.cet-menubar');
if (cetMenubar) {
const menuOpenObserver = new MutationObserver(() => {
let isOpen = false;
for (const child of cetMenubar.children) {
if (child.classList.contains('open')) {
isOpen = true;
break;
}
}
const navBarBackground = $<ElectronHTMLElement>('#nav-bar-background');
if (navBarBackground) {
navBarBackground.style.webkitAppRegion = isOpen ? 'no-drag' : 'drag';
}
});
menuOpenObserver.observe(cetMenubar, { subtree: true, attributeFilter: ['class'] });
}
}
function setNavbarMargin() {
const navBarBackground = $<HTMLElement>('#nav-bar-background');
if (navBarBackground) {
navBarBackground.style.right
= $<HTMLElement & { playerPageOpen_: boolean }>('ytmusic-app-layout')?.playerPageOpen_
? '0px'
: '12px';
}
}

View File

@ -0,0 +1,10 @@
const Icons = {
submenu: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><polyline points="9 6 15 12 9 18" /></svg>',
checkbox: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10" /></svg>',
radio: {
checked: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style="padding: 2px"><path fill="currentColor" d="M10,5 C7.2,5 5,7.2 5,10 C5,12.8 7.2,15 10,15 C12.8,15 15,12.8 15,10 C15,7.2 12.8,5 10,5 L10,5 Z M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z" /></svg>',
unchecked: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" style="padding: 2px"><path fill="currentColor" d="M10,0 C4.5,0 0,4.5 0,10 C0,15.5 4.5,20 10,20 C15.5,20 20,15.5 20,10 C20,4.5 15.5,0 10,0 L10,0 Z M10,18 C5.6,18 2,14.4 2,10 C2,5.6 5.6,2 10,2 C14.4,2 18,5.6 18,10 C18,14.4 14.4,18 10,18 L10,18 Z" /></svg>',
},
};
export default Icons;

View File

@ -0,0 +1,125 @@
import { nativeImage, type MenuItem, ipcRenderer, Menu } from 'electron';
import Icons from './icons';
import { ElementFromHtml } from '../../utils';
interface PanelOptions {
placement?: 'bottom' | 'right';
order?: number;
}
export const createPanel = (
parent: HTMLElement,
anchor: HTMLElement,
items: MenuItem[],
options: PanelOptions = { placement: 'bottom', order: 0 },
) => {
const childPanels: HTMLElement[] = [];
const panel = document.createElement('menu-panel');
panel.style.zIndex = `${options.order}`;
const updateIconState = (iconWrapper: HTMLElement, item: MenuItem) => {
if (item.type === 'checkbox') {
if (item.checked) iconWrapper.innerHTML = Icons.checkbox;
else iconWrapper.innerHTML = '';
} else if (item.type === 'radio') {
if (item.checked) iconWrapper.innerHTML = Icons.radio.checked;
else iconWrapper.innerHTML = Icons.radio.unchecked;
} else {
const nativeImageIcon = typeof item.icon === 'string' ? nativeImage.createFromPath(item.icon) : item.icon;
const iconURL = nativeImageIcon?.toDataURL();
if (iconURL) iconWrapper.style.background = `url(${iconURL})`;
}
};
const radioGroups: [MenuItem, HTMLElement][] = [];
items.map((item) => {
if (item.type === 'separator') return panel.appendChild(document.createElement('menu-separator'));
const menu = document.createElement('menu-item');
const iconWrapper = document.createElement('menu-icon');
updateIconState(iconWrapper, item);
menu.appendChild(iconWrapper);
menu.append(item.label);
menu.addEventListener('click', async () => {
await ipcRenderer.invoke('menu-event', item.commandId);
const menuItem = await ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
if (menuItem) {
updateIconState(iconWrapper, menuItem);
if (menuItem.type === 'radio') {
await Promise.all(
radioGroups.map(async ([item, iconWrapper]) => {
if (item.commandId === menuItem.commandId) return;
const newItem = await ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
if (newItem) updateIconState(iconWrapper, newItem);
})
);
}
}
});
if (item.type === 'radio') {
radioGroups.push([item, iconWrapper]);
}
if (item.type === 'submenu') {
const subMenuIcon = document.createElement('menu-icon');
subMenuIcon.appendChild(ElementFromHtml(Icons.submenu));
menu.appendChild(subMenuIcon);
const [child, , children] = createPanel(parent, menu, item.submenu?.items ?? [], {
placement: 'right',
order: (options?.order ?? 0) + 1,
});
childPanels.push(child);
children.push(...children);
}
panel.appendChild(menu);
});
/* methods */
const isOpened = () => panel.getAttribute('open') === 'true';
const close = () => panel.setAttribute('open', 'false');
const open = () => {
const rect = anchor.getBoundingClientRect();
if (options.placement === 'bottom') {
panel.style.setProperty('--x', `${rect.x}px`);
panel.style.setProperty('--y', `${rect.y + rect.height}px`);
} else {
panel.style.setProperty('--x', `${rect.x + rect.width}px`);
panel.style.setProperty('--y', `${rect.y}px`);
}
panel.setAttribute('open', 'true');
};
anchor.addEventListener('click', () => {
if (isOpened()) close();
else open();
});
document.body.addEventListener('click', (event) => {
const path = event.composedPath();
const isInside = path.some((it) => it === panel || it === anchor || childPanels.includes(it as HTMLElement));
if (!isInside) close();
});
parent.appendChild(panel);
return [
panel,
{ isOpened, close, open },
childPanels,
] as const;
};

View File

@ -1,113 +0,0 @@
/* increase font size for menu and menuItems */
.titlebar,
.menubar-menu-container .action-label {
font-size: 14px !important;
}
/* fixes nav-bar-background opacity bug, reposition it, and allows clicking scrollbar through it */
#nav-bar-background {
opacity: 1 !important;
pointer-events: none !important;
top: 30px !important;
height: 75px !important;
}
/* fix top gap between nav-bar and browse-page */
#browse-page {
padding-top: 0 !important;
}
/* fix navbar hiding library items */
ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_LIBRARY_CONTENT_LANDING_PAGE"],
ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_PRIVATELY_OWNED_CONTENT_LANDING_PAGE"] {
top: 50px;
position: relative;
}
/* remove window dragging for nav bar (conflict with titlebar drag) */
ytmusic-nav-bar,
.tab-titleiron-icon,
ytmusic-pivot-bar-item-renderer {
-webkit-app-region: unset !important;
}
/* move up item selection renderers */
ytmusic-item-section-renderer.stuck #header.ytmusic-item-section-renderer,
ytmusic-tabs.stuck {
top: calc(var(--ytmusic-nav-bar-height) - 15px) !important;
}
/* fix weird positioning in search screen*/
ytmusic-header-renderer.ytmusic-search-page {
position: unset !important;
}
/* Move navBar downwards */
ytmusic-nav-bar[slot="nav-bar"] {
top: 17px !important;
}
/* fix page progress bar position*/
yt-page-navigation-progress,
#progress.yt-page-navigation-progress {
top: 30px !important;
}
/* custom scrollbar */
::-webkit-scrollbar {
width: 12px;
background-color: #030303;
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
}
/* hover effect for both scrollbar area, and scrollbar 'thumb' */
::-webkit-scrollbar:hover {
background-color: rgba(15, 15, 15, 0.699);
}
/* the scrollbar 'thumb' ...that marque oval shape in a scrollbar */
::-webkit-scrollbar-thumb:vertical {
border: 2px solid rgba(0, 0, 0, 0);
background: #3a3a3a;
background-clip: padding-box;
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
}
::-webkit-scrollbar-thumb:vertical:active {
background: #4d4c4c; /* some darker color when you click it */
border-radius: 100px;
-moz-border-radius: 100px;
-webkit-border-radius: 100px;
}
.cet-menubar-menu-container .cet-action-item {
background-color: inherit
}
/** hideMenu toggler **/
.cet-window-icon {
-webkit-app-region: no-drag;
}
.cet-window-icon img {
-webkit-user-drag: none;
filter: invert(50%);
}
/** make navbar draggable **/
#nav-bar-background {
-webkit-app-region: drag;
}
ytmusic-nav-bar input,
ytmusic-nav-bar span,
ytmusic-nav-bar [role="button"],
ytmusic-nav-bar yt-icon,
tp-yt-iron-dropdown {
-webkit-app-region: no-drag;
}

View File

@ -0,0 +1,148 @@
:root {
--titlebar-background-color: #030303;
--menu-bar-height: 36px;
}
title-bar {
-webkit-app-region: drag;
box-sizing: border-box;
position: fixed;
top: 0;
z-index: 10000000;
width: 100%;
height: var(--menu-bar-height, 36px);
display: flex;
flex-flow: row;
justify-content: flex-start;
align-items: center;
gap: 4px;
color: #f1f1f1;
font-size: 14px;
padding: 4px 12px;
padding-left: var(--offset-left, 12px);
background-color: var(--titlebar-background-color, #030303);
user-select: none;
transition: opacity 200ms ease 0s, background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
}
menu-button {
-webkit-app-region: none;
display: flex;
justify-content: center;
align-items: center;
align-self: stretch;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
}
menu-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
menu-panel {
position: fixed;
top: var(--y, 0);
left: var(--x, 0);
max-height: calc(100vh - var(--menu-bar-height, 36px) - 16px - var(--y, 0));
display: flex;
flex-flow: column;
justify-content: flex-start;
align-items: stretch;
gap: 0;
overflow: auto;
padding: 4px;
border-radius: 8px;
pointer-events: none;
background-color: color-mix(in srgb, var(--titlebar-background-color, #030303) 50%, rgba(0, 0, 0, 0.1));
backdrop-filter: blur(8px);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 0;
opacity: 0;
transform: scale(0.8);
transform-origin: top left;
transition: opacity 200ms ease 0s, transform 200ms ease 0s;
}
menu-panel[open="true"] {
pointer-events: all;
opacity: 1;
transform: scale(1);
}
menu-item {
-webkit-app-region: none;
min-height: 32px;
height: 32px;
display: grid;
grid-template-columns: 32px 1fr minmax(32px, auto);
justify-content: flex-start;
align-items: center;
border-radius: 4px;
cursor: pointer;
}
menu-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
menu-item > menu-icon {
height: 32px;
padding: 4px;
box-sizing: border-box;
}
menu-separator {
min-height: 1px;
height: 1px;
margin: 4px 0;
background-color: rgba(255, 255, 255, 0.2);
}
/* classes */
.title-bar-icon {
height: calc(100% - 8px);
object-fit: cover;
margin-left: -4px;
}
/* youtube-music style */
ytmusic-app-layout {
margin-top: var(--menu-bar-height, 36px) !important;
}
ytmusic-app-layout>[slot=nav-bar], #nav-bar-background.ytmusic-app-layout {
top: var(--menu-bar-height, 36px) !important;
}
#nav-bar-divider.ytmusic-app-layout {
top: calc(var(--ytmusic-nav-bar-height) + var(--menu-bar-height, 36px)) !important;
}
ytmusic-app[is-bauhaus-sidenav-enabled] #guide-spacer.ytmusic-app,
ytmusic-app[is-bauhaus-sidenav-enabled] #mini-guide-spacer.ytmusic-app {
margin-top: calc(var(--ytmusic-nav-bar-height) + var(--menu-bar-height, 36px)) !important;
}
ytmusic-app-layout>[slot=player-page] {
margin-top: var(--menu-bar-height);
height: calc(100vh - var(--menu-bar-height) - var(--ytmusic-nav-bar-height) - var(--ytmusic-player-bar-height)) !important;
}
ytmusic-guide-renderer {
height: calc(100vh - var(--menu-bar-height) - var(--ytmusic-nav-bar-height)) !important;
}

View File

@ -1,9 +1,8 @@
import { join } from 'node:path';
import { BrowserWindow, ipcMain, net } from 'electron';
import is from 'electron-is';
import { convert } from 'html-to-text';
import style from './style.css';
import { GetGeniusLyric } from './types';
import { cleanupName, SongInfo } from '../../providers/song-info';
@ -15,14 +14,14 @@ import type { ConfigType } from '../../config/dynamic';
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false;
export type LyricGeniusType = ConfigType<'lyric-genius'>;
export type LyricGeniusType = ConfigType<'lyrics-genius'>;
export default (win: BrowserWindow, options: LyricGeniusType) => {
if (options.romanizedLyrics) {
revRomanized = true;
}
injectCSS(win.webContents, join(__dirname, 'style.css'));
injectCSS(win.webContents, style);
ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => {
const metadata = extractedSongInfo;

View File

@ -3,8 +3,9 @@ import { BrowserWindow, MenuItem } from 'electron';
import { LyricGeniusType, toggleRomanized } from './back';
import { setOptions } from '../../config/plugins';
import { MenuTemplate } from '../../menu';
export default (_: BrowserWindow, options: LyricGeniusType) => [
export default (_: BrowserWindow, options: LyricGeniusType): MenuTemplate => [
{
label: 'Romanized Lyrics',
type: 'checkbox',

View File

@ -1,27 +0,0 @@
import { Actions, triggerAction } from '../utils';
export const CHANNEL = 'navigation';
export const ACTIONS = Actions;
export function goToNextPage() {
triggerAction(CHANNEL, Actions.NEXT);
}
// for HTML
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
(global as any).goToNextPage = goToNextPage;
export function goToPreviousPage() {
triggerAction(CHANNEL, Actions.BACK);
}
// for HTML
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
(global as any).goToPreviousPage = goToPreviousPage;
export default {
CHANNEL,
ACTIONS,
actions: {
goToNextPage,
goToPreviousPage,
},
};

View File

@ -1,39 +1,13 @@
import path from 'node:path';
import { BrowserWindow } from 'electron';
import { ACTIONS, CHANNEL } from './actions';
import style from './style.css';
import { injectCSS, listenAction } from '../utils';
import { injectCSS } from '../utils';
export function handle(win: BrowserWindow) {
injectCSS(win.webContents, path.join(__dirname, 'style.css'), () => {
injectCSS(win.webContents, style, () => {
win.webContents.send('navigation-css-ready');
});
listenAction(CHANNEL, (_, action) => {
switch (action) {
case ACTIONS.NEXT: {
if (win.webContents.canGoForward()) {
win.webContents.goForward();
}
break;
}
case ACTIONS.BACK: {
if (win.webContents.canGoBack()) {
win.webContents.goBack();
}
break;
}
default: {
console.log('Unknown action: ' + action);
}
}
});
}
export default handle;

View File

@ -1,13 +1,14 @@
import { ipcRenderer } from 'electron';
import { ElementFromFile, templatePath } from '../utils';
import forwardHTML from './templates/forward.html';
import backHTML from './templates/back.html';
import { ElementFromHtml } from '../utils';
export function run() {
ipcRenderer.on('navigation-css-ready', () => {
const forwardButton = ElementFromFile(
templatePath(__dirname, 'forward.html'),
);
const backButton = ElementFromFile(templatePath(__dirname, 'back.html'));
const forwardButton = ElementFromHtml(forwardHTML);
const backButton = ElementFromHtml(backHTML);
const menu = document.querySelector('#right-content');
if (menu) {

View File

@ -1,6 +1,6 @@
<div
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
onclick="goToPreviousPage()"
onclick="history.back()"
role="tab"
tab-id="FEmusic_back"
>

View File

@ -1,6 +1,6 @@
<div
class="style-scope ytmusic-pivot-bar-renderer navigation-item"
onclick="goToNextPage()"
onclick="history.forward()"
role="tab"
tab-id="FEmusic_next"
>

View File

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

View File

@ -9,6 +9,7 @@ import getSongControls from '../../providers/song-controls';
import registerCallback, { SongInfo } from '../../providers/song-info';
import { changeProtocolHandler } from '../../providers/protocol-handler';
import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray';
import { getMediaIconLocation } from '../utils';
let songControls: ReturnType<typeof getSongControls>;
let savedNotification: Notification | undefined;
@ -151,11 +152,6 @@ const getXml = (songInfo: SongInfo, iconSrc: string) => {
}
}
};
const iconLocation = app.isPackaged
? path.resolve(app.getPath('userData'), 'icons')
: path.resolve(__dirname, '..', '..', 'assets/media-icons-black');
const display = (kind: keyof typeof icons) => {
if (config.get('toastStyle') === ToastStyles.legacy) {
return `content="${icons[kind]}"`;
@ -163,7 +159,7 @@ const display = (kind: keyof typeof icons) => {
return `\
content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
imageUri="file:///${path.resolve(__dirname, iconLocation, `${kind}.png`)}"
imageUri="file:///${path.resolve(getMediaIconLocation(), `${kind}.png`)}"
`;
};

View File

@ -6,11 +6,13 @@ import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
import config from './config';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
export default (_win: BrowserWindow, options: ConfigType<'notifications'>) => [
...(is.linux()
? [
const getMenu = (options: ConfigType<'notifications'>): MenuTemplate => {
if (is.linux()) {
return [
{
label: 'Notification Priority',
submenu: urgencyLevels.map((level) => ({
@ -19,11 +21,10 @@ export default (_win: BrowserWindow, options: ConfigType<'notifications'>) => [
checked: options.urgency === level.value,
click: () => config.set('urgency', level.value),
})),
},
]
: []),
...(is.windows()
? [
}
];
} else if (is.windows()) {
return [
{
label: 'Interactive Notifications',
type: 'checkbox',
@ -59,8 +60,14 @@ export default (_win: BrowserWindow, options: ConfigType<'notifications'>) => [
label: 'Style',
submenu: getToastStyleMenuItems(options),
},
]
: []),
];
} else {
return [];
}
};
export default (_win: BrowserWindow, options: ConfigType<'notifications'>): MenuTemplate => [
...getMenu(options),
{
label: 'Show notification on unpause',
type: 'checkbox',
@ -79,8 +86,8 @@ export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
type: 'radio',
checked: options.toastStyle === index,
click: () => config.set('toastStyle', index),
};
} satisfies Electron.MenuItemConstructorOptions;
}
return array;
return array as Electron.MenuItemConstructorOptions[];
}

View File

@ -7,6 +7,7 @@ import config from './config';
import { cache } from '../../providers/decorators';
import { SongInfo } from '../../providers/song-info';
import { getAssetsDirectoryLocation } from '../utils';
const icon = 'assets/youtube-music.png';
const userData = app.getPath('userData');
@ -88,10 +89,9 @@ export const saveTempIcon = () => {
continue;
}
const iconPath = path.resolve(__dirname, '../../assets/media-icons-black', `${kind}.png`);
const iconPath = path.resolve(path.resolve(getAssetsDirectoryLocation(), 'media-icons-black'), `${kind}.png`);
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.copyFile(iconPath, destinationPath, () => {
});
fs.copyFile(iconPath, destinationPath, () => {});
}
};

View File

@ -1,9 +1,9 @@
import path from 'node:path';
import { app, BrowserWindow, ipcMain } from 'electron';
import { setOptions as setPluginOptions } from '../../config/plugins';
import style from './style.css';
import { injectCSS } from '../utils';
import { setOptions as setPluginOptions } from '../../config/plugins';
import type { ConfigType } from '../../config/dynamic';
@ -102,7 +102,7 @@ export default (_win: BrowserWindow, _options: PiPOptions) => {
options ??= _options;
win ??= _win;
setLocalOptions({ isInPiP });
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
injectCSS(win.webContents, style);
ipcMain.on('picture-in-picture', () => {
togglePiP();
});

View File

@ -2,9 +2,11 @@ import { ipcRenderer } from 'electron';
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
import keyEventAreEqual from 'keyboardevents-areequal';
import pipHTML from './templates/picture-in-picture.html';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromFile, templatePath } from '../utils';
import { ElementFromHtml } from '../utils';
import type { ConfigType } from '../../config/dynamic';
@ -16,9 +18,7 @@ function $<E extends Element = Element>(selector: string) {
let useNativePiP = false;
let menu: Element | null = null;
const pipButton = ElementFromFile(
templatePath(__dirname, 'picture-in-picture.html'),
);
const pipButton = ElementFromHtml(pipHTML);
// Will also clone
function replaceButton(query: string, button: Element) {

View File

@ -1,5 +1,7 @@
import sliderHTML from './templates/slider.html';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromFile, templatePath } from '../utils';
import { ElementFromHtml } from '../utils';
import { singleton } from '../../providers/decorators';
@ -7,7 +9,7 @@ function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector);
}
const slider = ElementFromFile(templatePath(__dirname, 'slider.html'));
const slider = ElementFromHtml(sliderHTML);
const roundToTwo = (n: number) => Math.round(n * 1e2) / 1e2;

View File

@ -1,7 +1,7 @@
import path from 'node:path';
import { globalShortcut, BrowserWindow } from 'electron';
import volumeHudStyle from './volume-hud.css';
import { injectCSS } from '../utils';
import type { ConfigType } from '../../config/dynamic';
@ -16,7 +16,7 @@ export const enabled = () => isEnabled;
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => {
isEnabled = true;
injectCSS(win.webContents, path.join(__dirname, 'volume-hud.css'));
injectCSS(win.webContents, volumeHudStyle);
if (options.globalShortcuts?.volumeUp) {
globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true));

View File

@ -55,7 +55,7 @@ function firstRun() {
setTooltip(options.savedVolume);
if (api.getVolume() !== options.savedVolume) {
api.setVolume(options.savedVolume);
setVolume(options.savedVolume);
}
}

View File

@ -35,7 +35,7 @@ export default (win: BrowserWindow, options: ConfigType<'precise-volume'>): Menu
{
label: 'Global Hotkeys',
type: 'checkbox',
checked: Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown),
checked: Boolean(options.globalShortcuts?.volumeUp ?? options.globalShortcuts?.volumeDown),
click: (item) => promptGlobalShortcuts(win, options, item),
},
{

View File

@ -4,6 +4,8 @@
transition: opacity 0.6s;
pointer-events: none;
padding: 10px;
text-shadow: rgba(0, 0, 0, 0.5) 0px 0px 12px;
}
ytmusic-player[player-ui-state_="MINIPLAYER"] #volumeHud {

View File

@ -1,15 +1,15 @@
import { ipcRenderer } from 'electron';
import { ElementFromFile, templatePath } from '../utils';
import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html';
import { ElementFromHtml } from '../utils';
import { YoutubePlayer } from '../../types/youtube-player';
function $(selector: string): HTMLElement | null {
return document.querySelector(selector);
}
const qualitySettingsButton = ElementFromFile(
templatePath(__dirname, 'qualitySettingsTemplate.html'),
);
const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate);
function setup(event: CustomEvent<YoutubePlayer>) {
const api = event.detail;

View File

@ -10,8 +10,6 @@ import defaultConfig from '../../config/defaults';
import type { GetPlayerResponse } from '../../types/get-player-response';
import type { ConfigType } from '../../config/dynamic';
let videoID: string;
export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => {
const { apiURL, categories } = {
...defaultConfig.plugins.sponsorblock,
@ -19,14 +17,13 @@ export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => {
};
ipcMain.on('video-src-changed', async (_, data: GetPlayerResponse) => {
videoID = data?.videoDetails?.videoId;
const segments = await fetchSegments(apiURL, categories);
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
win.webContents.send('sponsorblock-skip', segments);
});
};
const fetchSegments = async (apiURL: string, categories: string[]) => {
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoID}&categories=${JSON.stringify(
const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
categories,
)}`;
try {

View File

@ -4,18 +4,48 @@ import { BrowserWindow, nativeImage } from 'electron';
import getSongControls from '../../providers/song-controls';
import registerCallback, { SongInfo } from '../../providers/song-info';
let controls: {
playPause: () => void;
next: () => void;
previous: () => void;
};
let currentSongInfo: SongInfo;
import { getMediaIconLocation } from '../utils';
export default (win: BrowserWindow) => {
let currentSongInfo: SongInfo;
const { playPause, next, previous } = getSongControls(win);
controls = { playPause, next, previous };
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: string) => {
return path.join(getMediaIconLocation(), `${kind}.png`);
};
registerCallback((songInfo) => {
// Update currentsonginfo for win.on('show')
@ -29,39 +59,3 @@ export default (win: BrowserWindow) => {
setThumbar(win, currentSongInfo);
});
};
function 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() {
controls.previous();
},
}, {
tooltip: 'Play/Pause',
// Update icon based on play state
icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')),
click() {
controls.playPause();
},
}, {
tooltip: 'Next',
icon: nativeImage.createFromPath(get('next')),
click() {
controls.next();
},
},
]);
}
// Util
function get(kind: string) {
return path.join(__dirname, '../../assets/media-icons-black', `${kind}.png`);
}

View File

@ -3,6 +3,7 @@ import { TouchBar, NativeImage, BrowserWindow } from 'electron';
import registerCallback from '../../providers/song-info';
import getSongControls from '../../providers/song-controls';
export default (win: BrowserWindow) => {
const {
TouchBarButton,
TouchBarLabel,
@ -61,7 +62,7 @@ const touchBar = new TouchBar({
],
});
export default (win: BrowserWindow) => {
const { playPause, next, previous, dislike, like } = getSongControls(win);
// If the page is ready, register the callback

View File

@ -1,4 +1,5 @@
import { ipcMain, net, BrowserWindow } from 'electron';
import is from 'electron-is';
import registerCallback from '../../providers/song-info';
@ -41,7 +42,11 @@ const post = (data: Data) => {
method: 'POST',
headers,
body: JSON.stringify({ data }),
}).catch((error: { code: number, errno: number }) => console.log(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`));
}).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 (win: BrowserWindow) => {

View File

@ -1,9 +1,19 @@
import fs from 'node:fs';
import path from 'node:path';
import { ipcMain, ipcRenderer } from 'electron';
import { app, ipcMain, ipcRenderer } from 'electron';
import is from 'electron-is';
import { ValueOf } from '../utils/type-utils';
import defaultConfig from '../config/defaults';
export const getAssetsDirectoryLocation = () => path.resolve(__dirname, 'assets');
export const getMediaIconLocation = () =>
app.isPackaged
? path.resolve(app.getPath('userData'), 'icons')
: path.resolve(getAssetsDirectoryLocation(), 'media-icons-black');
// Creates a DOM element from an HTML string
export const ElementFromHtml = (html: string): HTMLElement => {
@ -46,29 +56,47 @@ export const fileExists = (
});
};
const cssToInject = new Map();
export const injectCSS = (webContents: Electron.WebContents, filepath: unknown, cb: (() => void) | undefined = undefined) => {
if (cssToInject.size === 0) {
const cssToInject = new Map<string, (() => void) | undefined>();
const cssToInjectFile = new Map<string, (() => void) | undefined>();
export const injectCSS = (webContents: Electron.WebContents, css: string, cb: (() => void) | undefined = undefined) => {
if (cssToInject.size === 0 && cssToInjectFile.size === 0) {
setupCssInjection(webContents);
}
cssToInject.set(filepath, cb);
cssToInject.set(css, cb);
};
export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: string, cb: (() => void) | undefined = undefined) => {
if (cssToInject.size === 0 && cssToInjectFile.size === 0) {
setupCssInjection(webContents);
}
cssToInjectFile.set(filepath, cb);
};
const setupCssInjection = (webContents: Electron.WebContents) => {
webContents.on('did-finish-load', () => {
cssToInject.forEach(async (callback: () => void | undefined, filepath: fs.PathOrFileDescriptor) => {
cssToInject.forEach(async (callback, css) => {
await webContents.insertCSS(css);
callback?.();
});
cssToInjectFile.forEach(async (callback, filepath) => {
await webContents.insertCSS(fs.readFileSync(filepath, 'utf8'));
callback?.();
});
});
};
export const getAllPlugins = () => {
const isDirectory = (source: fs.PathLike) => fs.lstatSync(source).isDirectory();
return fs
.readdirSync(__dirname)
.map((name) => path.join(__dirname, name))
.filter(isDirectory)
.map((name) => path.basename(name));
export const getAvailablePluginNames = () => {
return Object.keys(defaultConfig.plugins).filter((name) => {
if (is.windows() && name === 'touchbar') {
return false;
} else if (is.macOS() && name === 'taskbar-mediacontrol') {
return false;
} else if (is.linux() && (name === 'taskbar-mediacontrol' || name === 'touchbar')) {
return false;
}
return true;
});
};

View File

@ -1,15 +1,16 @@
import path from 'node:path';
import { BrowserWindow } from 'electron';
import forceHideStyle from './force-hide.css';
import buttonSwitcherStyle from './button-switcher.css';
import { injectCSS } from '../utils';
import type { ConfigType } from '../../config/dynamic';
export default (win: BrowserWindow, options: ConfigType<'video-toggle'>) => {
if (options.forceHide) {
injectCSS(win.webContents, path.join(__dirname, 'force-hide.css'));
injectCSS(win.webContents, forceHideStyle);
} else if (!options.mode || options.mode === 'custom') {
injectCSS(win.webContents, path.join(__dirname, 'button-switcher.css'));
injectCSS(win.webContents, buttonSwitcherStyle);
}
};

View File

@ -15,12 +15,12 @@
background: rgba(33, 33, 33, 0.4);
border-radius: 30px;
overflow: hidden;
width: 240px;
width: 20rem;
text-align: center;
font-size: 18px;
letter-spacing: 1px;
color: #fff;
padding-right: 120px;
padding-right: 10rem;
position: absolute;
}
@ -30,7 +30,7 @@
top: 0;
bottom: 0;
right: 0;
width: 120px;
width: 10rem;
display: flex;
align-items: center;
justify-content: center;
@ -55,7 +55,7 @@
}
.video-switch-button-checkbox:checked + .video-switch-button-label:before {
transform: translateX(120px);
transform: translateX(10rem);
transition: transform 300ms linear;
}

View File

@ -1,4 +1,6 @@
import { ElementFromFile, templatePath } from '../utils';
import buttonTemplate from './templates/button_template.html';
import { ElementFromHtml } from '../utils';
import { setOptions, isEnabled } from '../../config/plugins';
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/front';
@ -19,9 +21,7 @@ let player: HTMLElement & { videoMode_: boolean } | null;
let video: HTMLVideoElement | null;
let api: YoutubePlayer;
const switchButtonDiv = ElementFromFile(
templatePath(__dirname, 'button_template.html'),
);
const switchButtonDiv = ElementFromHtml(buttonTemplate);
export default (_options: ConfigType<'video-toggle'>) => {
if (_options.forceHide) {
@ -56,18 +56,12 @@ function setup(e: CustomEvent<YoutubePlayer>) {
$<HTMLVideoElement>('#player')?.prepend(switchButtonDiv);
if (options.hideVideo) {
const checkbox = $<HTMLInputElement>('.video-switch-button-checkbox');
if (checkbox) {
checkbox.checked = false;
}
changeDisplay(false);
setVideoState(!options.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) => {
@ -77,9 +71,8 @@ function setup(e: CustomEvent<YoutubePlayer>) {
// Button checked = show video
switchButtonDiv.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
options.hideVideo = target.checked;
changeDisplay(target.checked);
setOptions('video-toggle', options);
setVideoState(target.checked);
});
video?.addEventListener('srcChanged', videoStarted);
@ -104,7 +97,13 @@ function setup(e: CustomEvent<YoutubePlayer>) {
}
}
function changeDisplay(showVideo: boolean) {
function setVideoState(showVideo: boolean) {
options.hideVideo = !showVideo;
setOptions('video-toggle', options);
const checkbox = $<HTMLInputElement>('.video-switch-button-checkbox'); // custom mode
if (checkbox) checkbox.checked = !options.hideVideo;
if (player) {
player.style.margin = showVideo ? '' : 'auto 0px';
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
@ -123,7 +122,7 @@ function changeDisplay(showVideo: boolean) {
function videoStarted() {
if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') {
// Video doesn't exist -> switch to song mode
changeDisplay(false);
setVideoState(false);
// Hide toggle button
switchButtonDiv.style.display = 'none';
} else {
@ -137,7 +136,7 @@ function videoStarted() {
switchButtonDiv.style.display = 'initial';
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
if (!options.hideVideo && $<HTMLElement>('#song-video.ytmusic-player')?.style.display === 'none') {
changeDisplay(true);
setVideoState(true);
} else {
moveVolumeHud(!options.hideVideo);
}

View File

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

View File

@ -1,9 +1,6 @@
import { ButterchurnVisualizer as butterchurn, WaveVisualizer as wave, VudioVisualizer as vudio } from './visualizers';
import { Visualizer } from './visualizers/visualizer';
import vudio from './visualizers/vudio';
import wave from './visualizers/wave';
import butterchurn from './visualizers/butterchurn';
import defaultConfig from '../../config/defaults';
import type { ConfigType } from '../../config/dynamic';
@ -40,7 +37,7 @@ export default (options: ConfigType<'visualizer'>) => {
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'visualizer';
visualizerContainer.prepend(canvas);
visualizerContainer?.prepend(canvas);
}
const resizeCanvas = () => {

View File

@ -1,17 +1,11 @@
import { readdirSync } from 'node:fs';
import path from 'node:path';
import { BrowserWindow } from 'electron';
import { setMenuOptions } from '../../config/plugins';
import { MenuTemplate } from '../../menu';
import { setMenuOptions } from '../../config/plugins';
import type { ConfigType } from '../../config/dynamic';
const visualizerTypes = readdirSync(path.join(__dirname, 'visualizers'))
.map((filename) => path.parse(filename).name)
.filter((filename) => filename !== 'visualizer');
const visualizerTypes = ['butterchurn', 'vudio', 'wave']; // For bundling
export default (win: BrowserWindow, options: ConfigType<'visualizer'>): MenuTemplate => [
{

View File

@ -8,6 +8,8 @@ import { ConfigType } from '../../../config/dynamic';
const presets = ButterchurnPresets.getPresets();
class ButterchurnVisualizer extends Visualizer<Butterchurn> {
name = 'butterchurn';
visualizer: ReturnType<typeof Butterchurn.createVisualizer>;
private readonly renderingFrequencyInMs: number;

View File

@ -0,0 +1,5 @@
import ButterchurnVisualizer from './butterchurn';
import VudioVisualizer from './vudio';
import WaveVisualizer from './wave';
export { ButterchurnVisualizer, VudioVisualizer, WaveVisualizer };

View File

@ -1,6 +1,10 @@
import type { ConfigType } from '../../../config/dynamic';
export abstract class Visualizer<T> {
/**
* The name must be the same as the file name.
*/
abstract name: string;
abstract visualizer: T;
protected constructor(

View File

@ -5,6 +5,8 @@ import { Visualizer } from './visualizer';
import type { ConfigType } from '../../../config/dynamic';
class VudioVisualizer extends Visualizer<Vudio> {
name = 'vudio';
visualizer: Vudio;
constructor(

View File

@ -5,6 +5,8 @@ import { Visualizer } from './visualizer';
import type { ConfigType } from '../../../config/dynamic';
class WaveVisualizer extends Visualizer<Wave> {
name = 'wave';
visualizer: Wave;
constructor(

View File

@ -2,75 +2,106 @@ import { ipcRenderer } from 'electron';
import is from 'electron-is';
import config from './config';
import { fileExists } from './plugins/utils';
import setupSongInfo from './providers/song-info-front';
import { setupSongControls } from './providers/song-controls-front';
import { startingPages } from './providers/extracted-data';
import albumColorThemeRenderer from './plugins/album-color-theme/front';
import ambientModeRenderer from './plugins/ambient-mode/front';
import audioCompressorRenderer from './plugins/audio-compressor/front';
import bypassAgeRestrictionsRenderer from './plugins/bypass-age-restrictions/front';
import captionsSelectorRenderer from './plugins/captions-selector/front';
import compactSidebarRenderer from './plugins/compact-sidebar/front';
import crossfadeRenderer from './plugins/crossfade/front';
import disableAutoplayRenderer from './plugins/disable-autoplay/front';
import downloaderRenderer from './plugins/downloader/front';
import exponentialVolumeRenderer from './plugins/exponential-volume/front';
import inAppMenuRenderer from './plugins/in-app-menu/front';
import lyricsGeniusRenderer from './plugins/lyrics-genius/front';
import navigationRenderer from './plugins/navigation/front';
import noGoogleLogin from './plugins/no-google-login/front';
import pictureInPictureRenderer from './plugins/picture-in-picture/front';
import playbackSpeedRenderer from './plugins/playback-speed/front';
import preciseVolumeRenderer from './plugins/precise-volume/front';
import qualityChangerRenderer from './plugins/quality-changer/front';
import skipSilencesRenderer from './plugins/skip-silences/front';
import sponsorblockRenderer from './plugins/sponsorblock/front';
import videoToggleRenderer from './plugins/video-toggle/front';
import visualizerRenderer from './plugins/visualizer/front';
const plugins = config.plugins.getEnabled();
import adblockerPreload from './plugins/adblocker/preload';
import preciseVolumePreload from './plugins/precise-volume/preload';
import type { ConfigType, OneOfDefaultConfigKey } from './config/dynamic';
type PluginMapper<Type extends 'renderer' | 'preload' | 'backend'> = {
[Key in OneOfDefaultConfigKey]?: (
Type extends 'renderer' ? (options: ConfigType<Key>) => (Promise<void> | void) :
Type extends 'preload' ? () => (Promise<void> | void) :
never
)
};
const rendererPlugins: PluginMapper<'renderer'> = {
'album-color-theme': albumColorThemeRenderer,
'ambient-mode': ambientModeRenderer,
'audio-compressor': audioCompressorRenderer,
'bypass-age-restrictions': bypassAgeRestrictionsRenderer,
'captions-selector': captionsSelectorRenderer,
'compact-sidebar': compactSidebarRenderer,
'crossfade': crossfadeRenderer,
'disable-autoplay': disableAutoplayRenderer,
'downloader': downloaderRenderer,
'exponential-volume': exponentialVolumeRenderer,
'in-app-menu': inAppMenuRenderer,
'lyrics-genius': lyricsGeniusRenderer,
'navigation': navigationRenderer,
'no-google-login': noGoogleLogin,
'picture-in-picture': pictureInPictureRenderer,
'playback-speed': playbackSpeedRenderer,
'precise-volume': preciseVolumeRenderer,
'quality-changer': qualityChangerRenderer,
'skip-silences': skipSilencesRenderer,
'sponsorblock': sponsorblockRenderer,
'video-toggle': videoToggleRenderer,
'visualizer': visualizerRenderer,
};
const preloadPlugins: PluginMapper<'preload'> = {
'adblocker': adblockerPreload,
'precise-volume': preciseVolumePreload,
};
const enabledPluginNameAndOptions = config.plugins.getEnabled();
const $ = document.querySelector.bind(document);
let api: Element | null = null;
interface Actions {
CHANNEL: string;
ACTIONS: Record<string, string>,
actions: Record<string, () => void>,
enabledPluginNameAndOptions.forEach(async ([plugin, options]) => {
if (Object.hasOwn(preloadPlugins, plugin)) {
const handler = preloadPlugins[plugin];
try {
await handler?.();
} catch (error) {
console.error(`Error in plugin "${plugin}": ${String(error)}`);
}
plugins.forEach(async ([plugin, options]) => {
const preloadPath = await ipcRenderer.invoke(
'getPath',
__dirname,
'plugins',
plugin,
'preload.js',
) as string;
fileExists(preloadPath, () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-member-access
const run = require(preloadPath).default as (config: typeof options) => Promise<void>;
run(options);
});
const actionPath = await ipcRenderer.invoke(
'getPath',
__dirname,
'plugins',
plugin,
'actions.js',
) as string;
fileExists(actionPath, () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const actions = (require(actionPath) as Actions).actions ?? {};
// TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld(plugin + "Actions", actions);
for (const actionName of Object.keys(actions)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
(global as any)[actionName] = actions[actionName];
}
});
});
document.addEventListener('DOMContentLoaded', () => {
plugins.forEach(async ([plugin, options]) => {
const pluginPath = await ipcRenderer.invoke(
'getPath',
__dirname,
'plugins',
plugin,
'front.js',
) as string;
fileExists(pluginPath, () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-member-access
const run = require(pluginPath).default as (config: typeof options) => Promise<void>;
run(options);
});
enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => {
if (Object.hasOwn(rendererPlugins, pluginName)) {
const handler = rendererPlugins[pluginName];
try {
await handler?.(options as never);
} catch (error) {
console.error(`Error in plugin "${pluginName}": ${String(error)}`);
}
}
});
// Wait for complete load of youtube api
// Wait for complete load of YouTube api
listenForApiLoad();
// Inject song-info provider

View File

@ -1,21 +0,0 @@
// eslint-disable-next-line import/no-unresolved
import { Titlebar, Color } from 'custom-electron-titlebar';
const customTitlebarFunction = () => {
new Titlebar({
backgroundColor: Color.fromHex('#050505'),
minimizable: false,
maximizable: false,
menu: undefined,
});
const mainStyle = document.querySelector<HTMLElement>('#container')?.style;
if (mainStyle) {
mainStyle.width = '100%';
mainStyle.position = 'fixed';
mainStyle.border = 'unset';
}
};
module.exports = customTitlebarFunction;
export default customTitlebarFunction;

View File

@ -1,18 +1,10 @@
import path from 'node:path';
import is from 'electron-is';
import { getAssetsDirectoryLocation } from '../plugins/utils';
import { isEnabled } from '../config/plugins';
const iconPath = path.join(getAssetsDirectoryLocation(), 'youtube-music-tray.png');
const iconPath = path.join(__dirname, '..', 'assets', 'youtube-music-tray.png');
const customTitlebarPath = path.join(__dirname, 'prompt-custom-titlebar.js');
const promptOptions = !is.macOS() && isEnabled('in-app-menu') ? {
customStylesheet: 'dark',
// The following are used for custom titlebar
frame: false,
customScript: customTitlebarPath,
} : {
const promptOptions = {
customStylesheet: 'dark',
icon: iconPath,
};

View File

@ -40,7 +40,7 @@ this [wiki page](https://wiki.archlinux.org/index.php/Arch_User_Repository#Insta
If you get an error "is damaged and cant be opened." when launching the app, run the following in the Terminal:
```
```bash
xattr -cr /Applications/YouTube\ Music.app
```
@ -49,7 +49,7 @@ xattr -cr /Applications/YouTube\ Music.app
You can use the [Scoop package manager](https://scoop.sh) to install the `youtube-music` package from
the [`extras` bucket](https://github.com/ScoopInstaller/Extras).
```
```bash
scoop bucket add extras
scoop install extras/youtube-music
```
@ -61,7 +61,7 @@ official CLI package manager to install the `th-ch.YouTubeMusic` package.
true for the manual installation when trying to run the executable(.exe) after a manual download here on github (same
file).*
```
```bash
winget install th-ch.YouTubeMusic
```
@ -166,7 +166,7 @@ Some predefined themes are available in https://github.com/kerichdev/themes-for-
## Dev
```sh
```bash
git clone https://github.com/th-ch/youtube-music
cd youtube-music
npm
@ -184,42 +184,68 @@ Using plugins, you can:
Create a folder in `plugins/YOUR-PLUGIN-NAME`:
- if you need to manipulate the BrowserWindow, create a file `back.js` with the following template:
- if you need to manipulate the BrowserWindow, create a file with the following template:
```node
module.exports = win => {
// win is the BrowserWindow object
```typescript
// file: back.ts
export default (win: Electron.BrowserWindow, config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
// something
};
```
- if you need to change the front, create a file `front.js` with the following template:
then, register the plugin in `index.ts`:
```node
module.exports = () => {
```typescript
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/back';
// ...
const mainPlugins = {
// ...
'YOUR-PLUGIN-NAME': yourPlugin,
};
```
- if you need to change the front, create a file with the following template:
```typescript
// file: front.ts
export default (config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
// This function will be called as a preload script
// So you can use front features like `document.querySelector`
};
```
then, register the plugin in `preload.ts`:
```typescript
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/front';
const rendererPlugins: PluginMapper<'renderer'> = {
// ...
'YOUR-PLUGIN-NAME': yourPlugin,
};
```
### Common use cases
- injecting custom CSS: create a `style.css` file in the same folder then:
```node
const path = require("path");
const {injectCSS} = require("../utils");
```typescript
import path from 'node:path';
import { injectCSS } from '../utils';
// back.js
module.exports = win => {
injectCSS(win.webContents, path.join(__dirname, "style.css"));
// back.ts
export default (win: Electron.BrowserWindow) => {
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
};
```
- changing the HTML:
```node
// front.js
module.exports = () => {
```typescript
// front.ts
export default () => {
// Remove the login button
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
};
@ -243,7 +269,7 @@ using [electron-builder](https://github.com/electron-userland/electron-builder).
## Tests
```sh
```bash
npm run test
```

65
rollup.main.config.ts Normal file
View File

@ -0,0 +1,65 @@
import { defineConfig } from 'rollup';
import builtinModules from 'builtin-modules';
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolvePlugin from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
import { string } from 'rollup-plugin-string';
import css from 'rollup-plugin-import-css';
import wasmPlugin from '@rollup/plugin-wasm';
import copy from 'rollup-plugin-copy';
export default defineConfig({
plugins: [
typescript({
module: 'ESNext',
}),
nodeResolvePlugin({
browser: false,
preferBuiltins: true,
exportConditions: ['node', 'default', 'module', 'import'] ,
}),
commonjs({
ignoreDynamicRequires: true,
}),
wasmPlugin({
maxFileSize: 0,
targetEnv: 'browser',
}),
json(),
string({
include: '**/*.html',
}),
css(),
copy({
targets: [
{ src: 'error.html', dest: 'dist/' },
{ src: 'assets', dest: 'dist/' },
],
}),
terser({
ecma: 2020,
}),
{
closeBundle() {
if (!process.env.ROLLUP_WATCH) {
setTimeout(() => process.exit(0));
}
},
name: 'force-close'
},
],
input: './index.ts',
output: {
format: 'cjs',
name: '[name].js',
dir: './dist',
},
external: [
'electron',
'custom-electron-prompt',
'youtubei.js', // https://github.com/LuanRT/YouTube.js/pull/509
...builtinModules,
],
});

58
rollup.preload.config.ts Normal file
View File

@ -0,0 +1,58 @@
import { defineConfig } from 'rollup';
import builtinModules from 'builtin-modules';
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolvePlugin from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
import { string } from 'rollup-plugin-string';
import css from 'rollup-plugin-import-css';
import wasmPlugin from '@rollup/plugin-wasm';
import image from '@rollup/plugin-image';
export default defineConfig({
plugins: [
typescript({
module: 'ESNext',
}),
nodeResolvePlugin({
browser: false,
preferBuiltins: true,
}),
commonjs({
ignoreDynamicRequires: true,
}),
json(),
string({
include: '**/*.html',
}),
css(),
wasmPlugin({
maxFileSize: 0,
targetEnv: 'browser',
}),
image({ dom: true }),
terser({
ecma: 2020,
}),
{
closeBundle() {
if (!process.env.ROLLUP_WATCH) {
setTimeout(() => process.exit(0));
}
},
name: 'force-close'
},
],
input: './preload.ts',
output: {
format: 'cjs',
name: '[name].js',
dir: './dist',
},
external: [
'electron',
'custom-electron-prompt',
...builtinModules,
],
});

View File

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('node:path');
const { _electron: electron } = require('playwright');
@ -28,9 +30,6 @@ test('YouTube Music App - With default settings, app is launched and visible', a
await consentForm.click('button');
}
const title = await window.title();
expect(title.replaceAll(/\s/g, ' ')).toEqual('YouTube Music');
const url = window.url();
expect(url.startsWith('https://music.youtube.com')).toBe(true);

View File

@ -6,6 +6,8 @@ import { restart } from './providers/app-controls';
import config from './config';
import getSongControls from './providers/song-controls';
import { getAssetsDirectoryLocation } from './plugins/utils';
import type { MenuTemplate } from './menu';
// Prevent tray being garbage collected
@ -39,7 +41,7 @@ export const setUpTray = (app: Electron.App, win: Electron.BrowserWindow) => {
}
const { playPause, next, previous } = getSongControls(win);
const iconPath = path.join(__dirname, 'assets', 'youtube-music-tray.png');
const iconPath = path.join(getAssetsDirectoryLocation(), 'youtube-music-tray.png');
const trayIcon = nativeImage.createFromPath(iconPath).resize({
width: 16,

View File

@ -15,7 +15,6 @@
"skipLibCheck": true
},
"exclude": [
"*.config.ts",
"./dist"
],
"paths": {

View File

@ -54,3 +54,8 @@ ytmusic-nav-bar > div.left-content > a,
ytmusic-nav-bar > div.left-content > a > picture > img {
-webkit-user-drag: none;
}
/* yt-music bugs */
tp-yt-paper-item.ytmusic-guide-entry-renderer::before {
border-radius: 8px !important;
}

37
youtube-music.d.ts vendored Normal file
View File

@ -0,0 +1,37 @@
declare module '*.html' {
const html: string;
export default html;
}
declare module '*.svg' {
const element: SVGAElement;
export default element;
}
declare module '*.png' {
const element: HTMLImageElement;
export default element;
}
declare module '*.jpg' {
const element: HTMLImageElement;
export default element;
}
declare module '*.css' {
const css: string;
export default css;
}
declare module 'rollup-plugin-string' {
import type { Plugin } from 'rollup';
interface PluginOptions {
include?: string[] | string;
exclude?: string[] | string;
minifier?: unknown;
}
export function string(options?: PluginOptions): Plugin;
}