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 .eslintrc.js
rollup.main.config.ts
rollup.preload.config.ts

View File

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

View File

@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout Repository" - name: "Checkout Repository"
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: "Dependency Review" - name: "Dependency Review"
uses: actions/dependency-review-action@v3 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: '', proxy: '',
startingPage: '', startingPage: '',
overrideUserAgent: false, overrideUserAgent: false,
themes: {} as string[], themes: [] as string[],
}, },
/** please order alphabetically */
'plugins': { 'plugins': {
// Enabled plugins
'navigation': {
enabled: true,
},
'adblocker': { 'adblocker': {
enabled: true, enabled: true,
cache: true, cache: true,
blocker: 'With blocklists', blocker: 'With blocklists',
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt" additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
disableDefaultLists: [], disableDefaultLists: false,
}, },
'album-color-theme': {}, 'album-color-theme': {},
'ambient-mode': {}, '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': {}, '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': { 'shortcuts': {
enabled: false, enabled: false,
overrideMediaKeys: false, overrideMediaKeys: false,
@ -97,53 +180,8 @@ const defaultConfig = {
next: '', next: '',
} as Record<string, string>, } as Record<string, string>,
}, },
'downloader': { 'skip-silences': {
enabled: false, onlySkipBeginning: 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
}, },
'sponsorblock': { 'sponsorblock': {
enabled: false, enabled: false,
@ -157,6 +195,9 @@ const defaultConfig = {
'music_offtopic', 'music_offtopic',
], ],
}, },
'taskbar-mediacontrol': {},
'touchbar': {},
'tuna-obs': {},
'video-toggle': { 'video-toggle': {
enabled: false, enabled: false,
hideVideo: false, hideVideo: false,
@ -164,34 +205,6 @@ const defaultConfig = {
forceHide: false, forceHide: false,
align: '', 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': { 'visualizer': {
enabled: false, enabled: false,
type: 'butterchurn', type: 'butterchurn',

View File

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

136
index.ts
View File

@ -1,6 +1,6 @@
import path from 'node:path'; 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 enhanceWebRequest from 'electron-better-web-request';
import is from 'electron-is'; import is from 'electron-is';
import unhandled from 'electron-unhandled'; import unhandled from 'electron-unhandled';
@ -11,13 +11,40 @@ import { BetterWebRequest } from 'electron-better-web-request/lib/electron-bette
import config from './config'; import config from './config';
import { setApplicationMenu } from './menu'; import { setApplicationMenu } from './menu';
import { fileExists, injectCSS } from './plugins/utils'; import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils';
import { isTesting } from './utils/testing'; import { isTesting } from './utils/testing';
import { setUpTray } from './tray'; import { setUpTray } from './tray';
import { setupSongInfo } from './providers/song-info'; import { setupSongInfo } from './providers/song-info';
import { restart, setupAppControls } from './providers/app-controls'; import { restart, setupAppControls } from './providers/app-controls';
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler'; 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 // Catch errors and log them
unhandled({ unhandled({
@ -28,7 +55,6 @@ unhandled({
// Disable Node options if the env var is set // Disable Node options if the env var is set
process.env.NODE_OPTIONS = ''; process.env.NODE_OPTIONS = '';
const { app } = electron;
// Prevent window being garbage collected // Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow | null; let mainWindow: Electron.BrowserWindow | null;
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
@ -38,7 +64,9 @@ if (!gotTheLock) {
app.exit(); 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 (config.get('options.disableHardwareAcceleration')) {
if (is.dev()) { if (is.dev()) {
console.log('Disabling hardware acceleration'); console.log('Disabling hardware acceleration');
@ -74,8 +102,50 @@ function onClosed() {
mainWindow = null; 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) { function loadPlugins(win: BrowserWindow) {
injectCSS(win.webContents, path.join(__dirname, 'youtube-music.css')); injectCSS(win.webContents, youtubeMusicCSS);
// Load user CSS // Load user CSS
const themes: string[] = config.get('options.themes'); const themes: string[] = config.get('options.themes');
if (Array.isArray(themes)) { if (Array.isArray(themes)) {
@ -83,7 +153,7 @@ function loadPlugins(win: BrowserWindow) {
fileExists( fileExists(
cssFile, cssFile,
() => { () => {
injectCSS(win.webContents, cssFile); injectCSSAsFile(win.webContents, cssFile);
}, },
() => { () => {
console.warn(`CSS file "${cssFile}" does not exist, ignoring`); 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()) { for (const [plugin, options] of config.plugins.getEnabled()) {
console.log('Loaded plugin - ' + plugin); try {
const pluginPath = path.join(__dirname, 'plugins', plugin, 'back.js'); if (Object.hasOwn(mainPlugins, plugin)) {
fileExists(pluginPath, () => { console.log('Loaded plugin - ' + plugin);
// eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-member-access const handler = mainPlugins[plugin as keyof typeof mainPlugins];
const handle = require(pluginPath).default as (window: BrowserWindow, option: typeof options) => void; if (handler) {
handle(win, options); 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, frame: !is.macOS() && !useInlineMenu,
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 36,
},
titleBarStyle: useInlineMenu titleBarStyle: useInlineMenu
? 'hidden' ? 'hidden'
: (is.macOS() : (is.macOS()
@ -150,7 +229,7 @@ function createMainWindow() {
const { x, y } = windowPosition; const { x, y } = windowPosition;
const winSize = win.getSize(); const winSize = win.getSize();
const displaySize const displaySize
= electron.screen.getDisplayNearestPoint(windowPosition).bounds; = screen.getDisplayNearestPoint(windowPosition).bounds;
if ( if (
x + winSize[0] < displaySize.x - 8 x + winSize[0] < displaySize.x - 8
|| x - winSize[0] > displaySize.x + displaySize.width || x - winSize[0] > displaySize.x + displaySize.width
@ -182,7 +261,7 @@ function createMainWindow() {
win.webContents.loadURL(urlToLoad); win.webContents.loadURL(urlToLoad);
win.on('closed', onClosed); 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 size = config.get('window-size');
const position = config.get('window-position'); const position = config.get('window-position');
@ -205,8 +284,7 @@ function createMainWindow() {
type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture']; type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture'];
const setPiPOptions = config.plugins.isEnabled('picture-in-picture') const setPiPOptions = config.plugins.isEnabled('picture-in-picture')
// eslint-disable-next-line @typescript-eslint/no-var-requires // 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')) ? (key: string, value: unknown) => pipSetOptions({ [key]: value })
.setOptions({ [key]: value })
: () => {}; : () => {};
win.on('move', () => { win.on('move', () => {
@ -353,7 +431,7 @@ app.on('window-all-closed', () => {
} }
// Unregister all shortcuts. // Unregister all shortcuts.
electron.globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
}); });
app.on('activate', () => { app.on('activate', () => {
@ -374,7 +452,7 @@ app.on('ready', () => {
console.log('Clearing app cache.'); console.log('Clearing app cache.');
} }
electron.session.defaultSession.clearCache(); session.defaultSession.clearCache();
clearTimeout(clearCacheTimeout); clearTimeout(clearCacheTimeout);
}, 20_000); }, 20_000);
} }
@ -389,7 +467,7 @@ app.on('ready', () => {
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) { if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) {
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk'); const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk');
try { // Check if shortcut is registered and valid 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 ( if (
shortcutDetails.target !== appLocation shortcutDetails.target !== appLocation
|| shortcutDetails.appUserModelId !== appID || shortcutDetails.appUserModelId !== appID
@ -397,7 +475,7 @@ app.on('ready', () => {
throw 'needUpdate'; throw 'needUpdate';
} }
} catch (error) { // If not valid -> Register shortcut } catch (error) { // If not valid -> Register shortcut
electron.shell.writeShortcutLink( shell.writeShortcutLink(
shortcutPath, shortcutPath,
error === 'needUpdate' ? 'update' : 'create', error === 'needUpdate' ? 'update' : 'create',
{ {
@ -466,11 +544,11 @@ app.on('ready', () => {
message: 'A new version is available', message: 'A new version is available',
detail: `A new version is available and can be downloaded at ${downloadLink}`, 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) { switch (dialogOutput.response) {
// Download // Download
case 1: { case 1: {
electron.shell.openExternal(downloadLink); shell.openExternal(downloadLink);
break; break;
} }
@ -489,7 +567,7 @@ app.on('ready', () => {
} }
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) { if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
electron.dialog.showMessageBox(mainWindow, { dialog.showMessageBox(mainWindow, {
type: 'info', title: 'Hide Menu Enabled', type: 'info', title: 'Hide Menu Enabled',
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)", 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')); console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
} }
electron.dialog.showMessageBox(win, { dialog.showMessageBox(win, {
type: 'error', type: 'error',
title: 'Window Unresponsive', title: 'Window Unresponsive',
message: 'The Application is 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 // HACK: electron-better-web-request's typing is wrong
type BetterSession = Omit<Electron.Session, 'webRequest'> & { webRequest: BetterWebRequest & Electron.WebRequest }; type BetterSession = Omit<Electron.Session, 'webRequest'> & { webRequest: BetterWebRequest & Electron.WebRequest };
function removeContentSecurityPolicy( function removeContentSecurityPolicy(
session: BetterSession = electron.session.defaultSession as BetterSession, betterSession: BetterSession = session.defaultSession as BetterSession,
) { ) {
// Allows defining multiple "onHeadersReceived" listeners // Allows defining multiple "onHeadersReceived" listeners
// by enhancing the session. // by enhancing the session.
// Some plugins (e.g. adblocker) also define a "onHeadersReceived" listener // Some plugins (e.g. adblocker) also define a "onHeadersReceived" listener
enhanceWebRequest(session); enhanceWebRequest(betterSession);
// Custom listener to tweak the content security policy // Custom listener to tweak the content security policy
session.webRequest.onHeadersReceived((details, callback) => { betterSession.webRequest.onHeadersReceived((details, callback) => {
details.responseHeaders ??= {}; details.responseHeaders ??= {};
// Remove the content security policy // Remove the content security policy
@ -567,7 +645,7 @@ function removeContentSecurityPolicy(
type ResolverListener = { apply: () => Promise<Record<string, unknown>>; context: unknown }; type ResolverListener = { apply: () => Promise<Record<string, unknown>>; context: unknown };
// When multiple listeners are defined, apply them all // 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>>>( return listeners.reduce<Promise<Record<string, unknown>>>(
async (accumulator: Promise<Record<string, unknown>>, listener: ResolverListener) => { async (accumulator: Promise<Record<string, unknown>>, listener: ResolverListener) => {
const acc = await accumulator; const acc = await accumulator;

103
menu.ts
View File

@ -1,23 +1,50 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import is from 'electron-is'; import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron'; import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import { restart } from './providers/app-controls'; import { restart } from './providers/app-controls';
import { getAllPlugins } from './plugins/utils';
import config from './config'; import config from './config';
import { startingPages } from './providers/extracted-data'; import { startingPages } from './providers/extracted-data';
import promptOptions from './providers/prompt-options'; import promptOptions from './providers/prompt-options';
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 // True only if in-app-menu was loaded on launch
const inAppMenuActive = config.plugins.isEnabled('in-app-menu'); const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
const betaPlugins = ['crossfade', 'lumiastream']; 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 => ({ const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
label: label || plugin, label: label || plugin,
type: 'checkbox', type: 'checkbox',
@ -47,33 +74,30 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
{ {
label: 'Plugins', label: 'Plugins',
submenu: submenu:
getAllPlugins().map((plugin) => { getAvailablePluginNames().map((pluginName) => {
let pluginLabel = plugin; let pluginLabel = pluginName;
if (betaPlugins.includes(plugin)) { if (betaPlugins.includes(pluginLabel)) {
pluginLabel += ' [beta]'; pluginLabel += ' [beta]';
} }
const pluginPath = path.join(__dirname, 'plugins', plugin, 'menu.js'); if (Object.hasOwn(pluginMenus, pluginName)) {
if (existsSync(pluginPath)) { const getPluginMenu = pluginMenus[pluginName as keyof typeof pluginMenus];
if (!config.plugins.isEnabled(plugin)) {
return pluginEnabledMenu(plugin, pluginLabel, true, refreshMenu); 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 { return {
label: pluginLabel, label: pluginLabel,
submenu: [ submenu: [
pluginEnabledMenu(plugin, 'Enabled', true, refreshMenu), pluginEnabledMenu(pluginName, 'Enabled', true, refreshMenu),
{ type: 'separator' }, { type: 'separator' },
...getPluginMenu(win, config.plugins.getOptions(plugin), refreshMenu), ...getPluginMenu(win, config.plugins.getOptions(pluginName), refreshMenu),
], ],
} satisfies Electron.MenuItemConstructorOptions; } satisfies Electron.MenuItemConstructorOptions;
} }
return pluginEnabledMenu(plugin, pluginLabel); return pluginEnabledMenu(pluginName, pluginLabel);
}), }),
}, },
{ {
@ -97,14 +121,25 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}, },
{ {
label: 'Starting page', label: 'Starting page',
submenu: Object.keys(startingPages).map((name) => ({ submenu: (() => {
label: name, const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({
type: 'radio', label: name,
checked: config.get('options.startingPage') === name, type: 'radio',
click() { checked: config.get('options.startingPage') === name,
config.set('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', label: 'Visual Tweaks',
@ -152,7 +187,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
{ {
label: 'No theme', label: 'No theme',
type: 'radio', type: 'radio',
checked: !config.get('options.themes'), // Todo rename "themes" checked: config.get('options.themes')?.length === 0, // Todo rename "themes"
click() { click() {
config.set('options.themes', []); config.set('options.themes', []);
}, },
@ -160,8 +195,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Import custom CSS file', label: 'Import custom CSS file',
type: 'radio', type: 'normal',
checked: false,
async click() { async click() {
const { filePaths } = await dialog.showOpenDialog({ const { filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'CSS Files', extensions: ['css'] }], filters: [{ name: 'CSS Files', extensions: ['css'] }],
@ -275,11 +309,10 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
label: 'Advanced options', label: 'Advanced options',
submenu: [ submenu: [
{ {
label: 'Proxy', label: 'Set Proxy',
type: 'checkbox', type: 'normal',
checked: !!(config.get('options.proxy')), async click(item) {
click(item) { await setProxy(item, win);
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": { "build": {
"appId": "com.github.th-ch.youtube-music", "appId": "com.github.th-ch.youtube-music",
"productName": "YouTube Music", "productName": "YouTube Music",
"files": [
"!*",
"dist",
"license",
"!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": { "mac": {
"identity": null, "identity": null,
"files": [
"!*",
"dist",
"!dist/plugins/taskbar-mediacontrol${/*}",
"license",
"node_modules",
"package.json",
"tests"
],
"target": [ "target": [
{ {
"target": "dmg", "target": "dmg",
@ -38,15 +57,6 @@
}, },
"win": { "win": {
"icon": "assets/generated/icons/win/icon.ico", "icon": "assets/generated/icons/win/icon.ico",
"files": [
"!*",
"dist",
"!dist/plugins/touchbar${/*}",
"license",
"node_modules",
"package.json",
"tests"
],
"target": [ "target": [
{ {
"target": "nsis", "target": "nsis",
@ -69,15 +79,6 @@
}, },
"linux": { "linux": {
"icon": "assets/generated/icons/png", "icon": "assets/generated/icons/png",
"files": [
"!*",
"dist",
"!dist/plugins/{touchbar,taskbar-mediacontrol}${/*}",
"license",
"node_modules",
"package.json",
"tests"
],
"category": "AudioVideo", "category": "AudioVideo",
"target": [ "target": [
"AppImage", "AppImage",
@ -101,31 +102,29 @@
} }
}, },
"scripts": { "scripts": {
"test": "playwright test", "test": "npm run build && playwright test",
"test:debug": "DEBUG=pw:browser* playwright test", "test:debug": "DEBUG=pw:browser* npm run build && playwright test",
"start": "npm run tsc-and-copy && electron ./dist/index.js", "rollup:preload": "rollup -c rollup.preload.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs",
"start:debug": "ELECTRON_ENABLE_LOGGING=1 electron ./dist/index.js", "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", "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", "clean": "del-cli dist && del-cli pack",
"ytm-resource-copy-files": "copyfiles error.html youtube-music.css assets/**/* dist/", "dist": "npm run clean && npm run build && electron-builder --win --mac --linux -p never",
"copy-files": "copyfiles -u 1 plugins/**/*.html plugins/**/*.css plugins/**/*.bin plugins/**/*.js dist/plugins/", "dist:linux": "npm run clean && npm run build && electron-builder --linux -p never",
"tsc-and-copy": "tsc && npm run plugin:adblocker-without-tsc && npm run ytm-resource-copy-files && npm run copy-files", "dist:mac": "npm run clean && npm run build && electron-builder --mac dmg:x64 -p never",
"build": "npm run clean && npm run tsc-and-copy && electron-builder --win --mac --linux -p never", "dist:mac:arm64": "npm run clean && npm run build && electron-builder --mac dmg:arm64 -p never",
"build:linux": "npm run clean && npm run tsc-and-copy && electron-builder --linux -p never", "dist:win": "npm run clean && npm run build && electron-builder --win -p never",
"build:mac": "npm run clean && npm run tsc-and-copy && electron-builder --mac dmg:x64 -p never", "dist:win:x64": "npm run clean && npm run build && electron-builder --win nsis: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",
"lint": "eslint .", "lint": "eslint .",
"changelog": "auto-changelog", "changelog": "auto-changelog",
"plugins": "npm run plugin:adblocker && npm run plugin:bypass-age-restrictions", "plugins": "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",
"plugin:bypass-age-restrictions": "del-cli node_modules/simple-youtube-age-restriction-bypass/package.json && npm run generate:package simple-youtube-age-restriction-bypass", "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:linux": "npm run clean && npm run build && 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:mac": "npm run clean && npm run build && electron-builder --mac -p always",
"release:win": "npm run clean && npm run tsc-and-copy && electron-builder --win -p always", "release:win": "npm run clean && npm run build && electron-builder --win -p always",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"engines": { "engines": {
@ -142,7 +141,6 @@
"butterchurn-presets": "2.4.7", "butterchurn-presets": "2.4.7",
"conf": "10.2.0", "conf": "10.2.0",
"custom-electron-prompt": "1.5.7", "custom-electron-prompt": "1.5.7",
"custom-electron-titlebar": "4.1.6",
"electron-better-web-request": "1.0.1", "electron-better-web-request": "1.0.1",
"electron-debug": "3.2.0", "electron-debug": "3.2.0",
"electron-is": "3.0.0", "electron-is": "3.0.0",
@ -150,8 +148,8 @@
"electron-store": "8.1.0", "electron-store": "8.1.0",
"electron-unhandled": "4.0.1", "electron-unhandled": "4.0.1",
"electron-updater": "6.1.4", "electron-updater": "6.1.4",
"fast-average-color-node": "^2.6.0", "fast-average-color": "9.4.0",
"filenamify": "4.3.0", "filenamify": "6.0.0",
"howler": "2.2.4", "howler": "2.2.4",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"keyboardevent-from-electron-accelerator": "2.0.0", "keyboardevent-from-electron-accelerator": "2.0.0",
@ -160,33 +158,47 @@
"node-id3": "0.2.6", "node-id3": "0.2.6",
"simple-youtube-age-restriction-bypass": "git+https://github.com/MiepHD/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.5", "simple-youtube-age-restriction-bypass": "git+https://github.com/MiepHD/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.5",
"vudio": "2.1.1", "vudio": "2.1.1",
"x11": "2.3.0",
"youtubei.js": "6.4.1", "youtubei.js": "6.4.1",
"ytpl": "2.3.0" "ytpl": "2.3.0"
}, },
"overrides": { "overrides": {
"rollup": "4.0.2",
"node-gyp": "9.4.0",
"xml2js": "0.6.2", "xml2js": "0.6.2",
"dbus-next": "0.10.2",
"node-fetch": "2.7.0", "node-fetch": "2.7.0",
"@electron/universal": "1.4.2", "@electron/universal": "1.4.2",
"electron": "27.0.0-beta.9" "electron": "27.0.0-beta.9"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.38.1", "@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", "@total-typescript/ts-reset": "0.5.1",
"@types/electron-localshortcut": "3.1.1", "@types/electron-localshortcut": "3.1.1",
"@types/howler": "2.2.9", "@types/howler": "2.2.9",
"@types/html-to-text": "9.0.2", "@types/html-to-text": "9.0.2",
"@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/eslint-plugin": "6.7.4",
"auto-changelog": "2.4.0", "auto-changelog": "2.4.0",
"copyfiles": "2.4.1",
"del-cli": "5.1.0", "del-cli": "5.1.0",
"electron": "27.0.0-beta.9", "electron": "27.0.0-beta.9",
"electron-builder": "24.6.4", "electron-builder": "24.6.4",
"electron-devtools-installer": "3.2.0", "electron-devtools-installer": "3.2.0",
"eslint": "8.50.0", "eslint": "8.51.0",
"eslint-plugin-import": "2.28.1", "eslint-plugin-import": "2.28.1",
"eslint-plugin-prettier": "5.0.0", "eslint-plugin-prettier": "5.0.0",
"node-gyp": "9.4.0", "node-gyp": "9.4.0",
"playwright": "1.38.1", "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" "typescript": "5.2.2"
}, },
"auto-changelog": { "auto-changelog": {

View File

@ -1,8 +1,9 @@
// Used for caching // Used for caching
import path from 'node:path'; import path from 'node:path';
import { promises } from 'node:fs'; import fs, { promises } from 'node:fs';
import { ElectronBlocker } from '@cliqz/adblocker-electron'; import { ElectronBlocker } from '@cliqz/adblocker-electron';
import { app } from 'electron';
const SOURCES = [ const SOURCES = [
'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt', 'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt',
@ -20,19 +21,31 @@ export const loadAdBlockerEngine = (
session: Electron.Session | undefined = undefined, session: Electron.Session | undefined = undefined,
cache = true, cache = true,
additionalBlockLists = [], additionalBlockLists = [],
disableDefaultLists: boolean | string[] = false, disableDefaultLists: boolean | unknown[] = false,
) => { ) => {
// Only use cache if no additional blocklists are passed // 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 const cachingOptions
= cache && additionalBlockLists.length === 0 = cache && additionalBlockLists.length === 0
? { ? {
path: path.resolve(__dirname, 'ad-blocker-engine.bin'), path: path.join(cacheDirectory, 'adblocker-engine.bin'),
read: promises.readFile, read: promises.readFile,
write: promises.writeFile, write: promises.writeFile,
} }
: undefined; : undefined;
const lists = [ const lists = [
...(disableDefaultLists ? [] : SOURCES), ...(
(disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) ? [] : SOURCES
),
...additionalBlockLists, ...additionalBlockLists,
]; ];
@ -58,6 +71,3 @@ export const loadAdBlockerEngine = (
}; };
export default { 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,428 +7,429 @@
Parts of this code is derived from set-constant.js: Parts of this code is derived from set-constant.js:
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704 https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
*/ */
module.exports = () => {
{ {
const pruner = function (o) { const pruner = function (o) {
delete o.playerAds; delete o.playerAds;
delete o.adPlacements; delete o.adPlacements;
// //
if (o.playerResponse) { if (o.playerResponse) {
delete o.playerResponse.playerAds; delete o.playerResponse.playerAds;
delete o.playerResponse.adPlacements; delete o.playerResponse.adPlacements;
}
//
return o;
};
JSON.parse = new Proxy(JSON.parse, {
apply() {
return pruner(Reflect.apply(...arguments));
},
});
Response.prototype.json = new Proxy(Response.prototype.json, {
apply() {
return Reflect.apply(...arguments).then((o) => pruner(o));
},
});
}
(function () {
let cValue = 'undefined';
const chain = 'playerResponse.adPlacements';
const thisScript = document.currentScript;
//
switch (cValue) {
case 'null': {
cValue = null;
break;
}
case "''": {
cValue = '';
break;
}
case 'true': {
cValue = true;
break;
}
case 'false': {
cValue = false;
break;
}
case 'undefined': {
cValue = undefined;
break;
}
case 'noopFunc': {
cValue = function () {
};
break;
}
case 'trueFunc': {
cValue = function () {
return true;
};
break;
}
case 'falseFunc': {
cValue = function () {
return false;
};
break;
}
default: {
if (/^\d+$/.test(cValue)) {
cValue = Number.parseFloat(cValue);
//
if (isNaN(cValue)) {
return;
}
if (Math.abs(cValue) > 0x7F_FF) {
return;
}
} else {
return;
}
}
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) {
return true;
}
aborted
= v !== undefined
&& v !== null
&& cValue !== undefined
&& cValue !== null
&& typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let previousGetter;
let previousSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) {
return;
} }
if (odesc.get instanceof Function) { //
previousGetter = odesc.get; return o;
} };
if (odesc.set instanceof Function) { JSON.parse = new Proxy(JSON.parse, {
previousSetter = odesc.set; apply() {
} return pruner(Reflect.apply(...arguments));
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (previousGetter !== undefined) {
previousGetter();
}
//
return handler.getter();
},
set(a) {
if (previousSetter !== undefined) {
previousSetter(a);
}
//
handler.setter(a);
}, },
}); });
};
const trapChain = function (owner, chain) { Response.prototype.json = new Proxy(Response.prototype.json, {
const pos = chain.indexOf('.'); apply() {
if (pos === -1) { return Reflect.apply(...arguments).then((o) => pruner(o));
trapProp(owner, chain, false, { },
v: undefined, });
getter() { }
return document.currentScript === thisScript ? this.v : cValue;
}, (function () {
setter(a) { let cValue = 'undefined';
if (mustAbort(a) === false) { const chain = 'playerResponse.adPlacements';
const thisScript = document.currentScript;
//
switch (cValue) {
case 'null': {
cValue = null;
break;
}
case "''": {
cValue = '';
break;
}
case 'true': {
cValue = true;
break;
}
case 'false': {
cValue = false;
break;
}
case 'undefined': {
cValue = undefined;
break;
}
case 'noopFunc': {
cValue = function () {
};
break;
}
case 'trueFunc': {
cValue = function () {
return true;
};
break;
}
case 'falseFunc': {
cValue = function () {
return false;
};
break;
}
default: {
if (/^\d+$/.test(cValue)) {
cValue = Number.parseFloat(cValue);
//
if (isNaN(cValue)) {
return; return;
} }
cValue = a; if (Math.abs(cValue) > 0x7F_FF) {
}, return;
init(v) { }
if (mustAbort(v)) { } else {
return false; return;
}
}
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) {
return true;
}
aborted
= v !== undefined
&& v !== null
&& cValue !== undefined
&& cValue !== null
&& typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let previousGetter;
let previousSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) {
return;
}
if (odesc.get instanceof Function) {
previousGetter = odesc.get;
}
if (odesc.set instanceof Function) {
previousSetter = odesc.set;
}
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (previousGetter !== undefined) {
previousGetter();
} }
// //
return handler.getter();
},
set(a) {
if (previousSetter !== undefined) {
previousSetter(a);
}
//
handler.setter(a);
},
});
};
const trapChain = function (owner, chain) {
const pos = chain.indexOf('.');
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter() {
return document.currentScript === thisScript ? this.v : cValue;
},
setter(a) {
if (mustAbort(a) === false) {
return;
}
cValue = a;
},
init(v) {
if (mustAbort(v)) {
return false;
}
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === 'object' && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter() {
return this.v;
},
setter(a) {
this.v = a;
if (a instanceof Object) {
trapChain(a, chain);
}
},
init(v) {
this.v = v; this.v = v;
return true; return true;
}, },
}); });
// };
return;
}
// //
const prop = chain.slice(0, pos); trapChain(window, chain);
const v = owner[prop]; })();
(function () {
let cValue = 'undefined';
const thisScript = document.currentScript;
const chain = 'ytInitialPlayerResponse.adPlacements';
// //
chain = chain.slice(pos + 1); switch (cValue) {
if (v instanceof Object || (typeof v === 'object' && v !== null)) { case 'null': {
trapChain(v, chain); cValue = null;
return; break;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter() {
return this.v;
},
setter(a) {
this.v = a;
if (a instanceof Object) {
trapChain(a, chain);
}
},
init(v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
})();
(function () {
let cValue = 'undefined';
const thisScript = document.currentScript;
const chain = 'ytInitialPlayerResponse.adPlacements';
//
switch (cValue) {
case 'null': {
cValue = null;
break;
}
case "''": {
cValue = '';
break;
}
case 'true': {
cValue = true;
break;
}
case 'false': {
cValue = false;
break;
}
case 'undefined': {
cValue = undefined;
break;
}
case 'noopFunc': {
cValue = function () {
};
break;
}
case 'trueFunc': {
cValue = function () {
return true;
};
break;
}
case 'falseFunc': {
cValue = function () {
return false;
};
break;
}
default: {
if (/^\d+$/.test(cValue)) {
cValue = Number.parseFloat(cValue);
//
if (isNaN(cValue)) {
return;
}
if (Math.abs(cValue) > 0x7F_FF) {
return;
}
} else {
return;
}
}
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) {
return true;
}
aborted
= v !== undefined
&& v !== null
&& cValue !== undefined
&& cValue !== null
&& typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let previousGetter;
let previousSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) {
return;
} }
if (odesc.get instanceof Function) { case "''": {
previousGetter = odesc.get; cValue = '';
break;
} }
if (odesc.set instanceof Function) { case 'true': {
previousSetter = odesc.set; cValue = true;
break;
} }
}
// case 'false': {
Object.defineProperty(owner, prop, { cValue = false;
configurable, break;
get() { }
if (previousGetter !== undefined) {
previousGetter();
}
// case 'undefined': {
return handler.getter(); cValue = undefined;
}, break;
set(a) { }
if (previousSetter !== undefined) {
previousSetter(a);
}
// case 'noopFunc': {
handler.setter(a); cValue = function () {
}, };
});
};
const trapChain = function (owner, chain) { break;
const pos = chain.indexOf('.'); }
if (pos === -1) {
trapProp(owner, chain, false, { case 'trueFunc': {
v: undefined, cValue = function () {
getter() { return true;
return document.currentScript === thisScript ? this.v : cValue; };
},
setter(a) { break;
if (mustAbort(a) === false) { }
case 'falseFunc': {
cValue = function () {
return false;
};
break;
}
default: {
if (/^\d+$/.test(cValue)) {
cValue = Number.parseFloat(cValue);
//
if (isNaN(cValue)) {
return; return;
} }
cValue = a; if (Math.abs(cValue) > 0x7F_FF) {
}, return;
init(v) { }
if (mustAbort(v)) { } else {
return false; return;
}
}
}
//
let aborted = false;
const mustAbort = function (v) {
if (aborted) {
return true;
}
aborted
= v !== undefined
&& v !== null
&& cValue !== undefined
&& cValue !== null
&& typeof v !== typeof cValue;
return aborted;
};
/*
Support multiple trappers for the same property:
https://github.com/uBlockOrigin/uBlock-issues/issues/156
*/
const trapProp = function (owner, prop, configurable, handler) {
if (handler.init(owner[prop]) === false) {
return;
}
//
const odesc = Object.getOwnPropertyDescriptor(owner, prop);
let previousGetter;
let previousSetter;
if (odesc instanceof Object) {
if (odesc.configurable === false) {
return;
}
if (odesc.get instanceof Function) {
previousGetter = odesc.get;
}
if (odesc.set instanceof Function) {
previousSetter = odesc.set;
}
}
//
Object.defineProperty(owner, prop, {
configurable,
get() {
if (previousGetter !== undefined) {
previousGetter();
} }
// //
return handler.getter();
},
set(a) {
if (previousSetter !== undefined) {
previousSetter(a);
}
//
handler.setter(a);
},
});
};
const trapChain = function (owner, chain) {
const pos = chain.indexOf('.');
if (pos === -1) {
trapProp(owner, chain, false, {
v: undefined,
getter() {
return document.currentScript === thisScript ? this.v : cValue;
},
setter(a) {
if (mustAbort(a) === false) {
return;
}
cValue = a;
},
init(v) {
if (mustAbort(v)) {
return false;
}
//
this.v = v;
return true;
},
});
//
return;
}
//
const prop = chain.slice(0, pos);
const v = owner[prop];
//
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === 'object' && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter() {
return this.v;
},
setter(a) {
this.v = a;
if (a instanceof Object) {
trapChain(a, chain);
}
},
init(v) {
this.v = v; this.v = v;
return true; return true;
}, },
}); });
// };
return;
}
// //
const prop = chain.slice(0, pos); trapChain(window, chain);
const v = owner[prop]; })();
// };
chain = chain.slice(pos + 1);
if (v instanceof Object || (typeof v === 'object' && v !== null)) {
trapChain(v, chain);
return;
}
//
trapProp(owner, prop, true, {
v: undefined,
getter() {
return this.v;
},
setter(a) {
this.v = a;
if (a instanceof Object) {
trapChain(a, chain);
}
},
init(v) {
this.v = v;
return true;
},
});
};
//
trapChain(window, chain);
})();

View File

@ -1,6 +1,8 @@
import config, { blockers } from './config'; import config, { blockers } from './config';
export default () => { import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => {
return [ return [
{ {
label: 'Blocker', 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 () => { export default async () => {
if (await config.shouldUseBlocklists()) { if (await config.shouldUseBlocklists()) {
// Preload adblocker to inject scripts/styles // Preload adblocker to inject scripts/styles
require('@cliqz/adblocker-electron-preload'); injectCliqzPreload();
// eslint-disable-next-line @typescript-eslint/await-thenable // eslint-disable-next-line @typescript-eslint/await-thenable
} else if ((await config.get('blocker')) === config.blockers.InPlayer) { } else if ((await config.get('blocker')) === blockers.InPlayer) {
require('./inject.js'); inject();
} }
}; };

View File

@ -1,9 +1,9 @@
import { join } from 'node:path';
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
export default (win: BrowserWindow) => { 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 { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
export default (win: BrowserWindow) => { 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 { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
export default (win: BrowserWindow) => { 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 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 { YoutubePlayer } from '../../types/youtube-player';
import type { ConfigType } from '../../config/dynamic'; 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 $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
const captionsSettingsButton = ElementFromFile( const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
templatePath(__dirname, 'captions-settings-template.html'),
);
export default async () => { export default async () => {
// RENDERER // RENDERER

View File

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

View File

@ -7,11 +7,13 @@ import config from './config';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '../../providers/prompt-options';
import configOptions from '../../config/defaults'; import configOptions from '../../config/defaults';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
const defaultOptions = configOptions.plugins.crossfade; const defaultOptions = configOptions.plugins.crossfade;
export default (win: BrowserWindow) => [ export default (win: BrowserWindow): MenuTemplate => [
{ {
label: 'Advanced', label: 'Advanced',
async click() { 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) => { const timeUpdateListener = (e: Event) => {
if (e.target instanceof HTMLVideoElement) { if (e.target instanceof HTMLVideoElement) {
e.target.pause(); e.target.pause();
@ -6,13 +8,16 @@ export default () => {
}; };
document.addEventListener('apiLoaded', (apiEvent) => { 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') { if (name === 'dataloaded') {
apiEvent.detail.pauseVideo(); apiEvent.detail.pauseVideo();
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener); document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
} else {
document.querySelector<HTMLVideoElement>('video')?.removeEventListener('timeupdate', timeUpdateListener);
} }
}); };
apiEvent.detail.addEventListener('videodatachange', eventListener);
}, { once: true, passive: true }); }, { 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 prompt from 'custom-electron-prompt';
import { Electron } from 'playwright';
import { clear, connect, isConnected, registerRefresh } from './back'; import { clear, connect, isConnected, registerRefresh } from './back';
import { setMenuOptions } from '../../config/plugins'; import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '../../providers/prompt-options';
import { singleton } from '../../providers/decorators'; import { singleton } from '../../providers/decorators';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
@ -16,14 +15,14 @@ const registerRefreshOnce = singleton((refreshMenu: () => void) => {
type DiscordOptions = ConfigType<'discord'>; 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); registerRefreshOnce(refreshMenu);
return [ return [
{ {
label: isConnected() ? 'Connected' : 'Reconnect', label: isConnected() ? 'Connected' : 'Reconnect',
enabled: !isConnected(), enabled: !isConnected(),
click: connect, click: () => connect(),
}, },
{ {
label: 'Auto reconnect', label: 'Auto reconnect',

View File

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

View File

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

View File

@ -1,27 +1,60 @@
import path from 'node:path'; import path from 'node:path';
import { register } from 'electron-localshortcut'; 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'; import { injectCSS } from '../utils';
setupTitlebar();
// Tracks menu visibility // Tracks menu visibility
export default (win: BrowserWindow) => { export default (win: BrowserWindow) => {
// Css for custom scrollbar + disable drag area(was causing bugs) injectCSS(win.webContents, titlebarStyle);
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
attachTitlebarToWindow(win);
register(win, '`', () => { register(win, '`', () => {
win.webContents.send('toggleMenu'); 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'; 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'; import { isEnabled } from '../../config/plugins';
import config from '../../config';
type ElectronCSSStyleDeclaration = CSSStyleDeclaration & { webkitAppRegion: 'drag' | 'no-drag' };
type ElectronHTMLElement = HTMLElement & { style: ElectronCSSStyleDeclaration };
function $<E extends Element = Element>(selector: string) { function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector); return document.querySelector<E>(selector);
} }
const isMacOS = navigator.userAgent.includes('Macintosh');
export default () => { export default () => {
const visible = () => !!($('.cet-menubar')?.firstChild); let hideMenu = config.get('options.hideMenu');
const bar = new Titlebar({ const titleBar = document.createElement('title-bar');
icon: 'https://cdn-icons-png.flaticon.com/512/5358/5358672.png', const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
backgroundColor: Color.fromHex('#050505'), if (isMacOS) titleBar.style.setProperty('--offset-left', '70px');
itemBackgroundColor: Color.fromHex('#1d1d1d') ,
svgColor: Color.WHITE, logo.classList.add('title-bar-icon');
menu: config.get('options.hideMenu') ? null as unknown as Menu : undefined, const logoClick = () => {
}); hideMenu = !hideMenu;
bar.updateTitle(' '); let visibilityStyle: string;
if (hideMenu) {
visibilityStyle = 'hidden';
} else {
visibilityStyle = 'visible';
}
const menus = document.querySelectorAll<HTMLElement>('menu-button');
menus.forEach((menu) => {
menu.style.visibility = visibilityStyle;
});
};
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'; 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', () => { ipcRenderer.on('refreshMenu', () => {
if (visible()) { updateMenu();
bar.refreshMenu();
}
}); });
if (isEnabled('picture-in-picture')) { if (isEnabled('picture-in-picture')) {
ipcRenderer.on('pip-toggle', () => { 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) // 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', () => { document.addEventListener('apiLoaded', () => {
setNavbarMargin(); const htmlHeadStyle = $('head > div > style');
const playPageObserver = new MutationObserver(setNavbarMargin); if (htmlHeadStyle) {
const appLayout = $('ytmusic-app-layout'); // HACK: This is a hack to remove the scrollbar width
if (appLayout) { htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {');
playPageObserver.observe(appLayout, { attributeFilter: ['player-page-open_', 'playerPageOpen_'] });
} }
setupSearchOpenObserver();
setupMenuOpenObserver();
}, { once: true, passive: true }); }, { 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 { BrowserWindow, ipcMain, net } from 'electron';
import is from 'electron-is'; import is from 'electron-is';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import style from './style.css';
import { GetGeniusLyric } from './types'; import { GetGeniusLyric } from './types';
import { cleanupName, SongInfo } from '../../providers/song-info'; 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; const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false; let revRomanized = false;
export type LyricGeniusType = ConfigType<'lyric-genius'>; export type LyricGeniusType = ConfigType<'lyrics-genius'>;
export default (win: BrowserWindow, options: LyricGeniusType) => { export default (win: BrowserWindow, options: LyricGeniusType) => {
if (options.romanizedLyrics) { if (options.romanizedLyrics) {
revRomanized = true; revRomanized = true;
} }
injectCSS(win.webContents, join(__dirname, 'style.css')); injectCSS(win.webContents, style);
ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => { ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => {
const metadata = extractedSongInfo; const metadata = extractedSongInfo;

View File

@ -3,8 +3,9 @@ import { BrowserWindow, MenuItem } from 'electron';
import { LyricGeniusType, toggleRomanized } from './back'; import { LyricGeniusType, toggleRomanized } from './back';
import { setOptions } from '../../config/plugins'; import { setOptions } from '../../config/plugins';
import { MenuTemplate } from '../../menu';
export default (_: BrowserWindow, options: LyricGeniusType) => [ export default (_: BrowserWindow, options: LyricGeniusType): MenuTemplate => [
{ {
label: 'Romanized Lyrics', label: 'Romanized Lyrics',
type: 'checkbox', 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 { 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) { export function handle(win: BrowserWindow) {
injectCSS(win.webContents, path.join(__dirname, 'style.css'), () => { injectCSS(win.webContents, style, () => {
win.webContents.send('navigation-css-ready'); 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; export default handle;

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import path from 'node:path';
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
import style from './style.css';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
export default (win: BrowserWindow) => { 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 registerCallback, { SongInfo } from '../../providers/song-info';
import { changeProtocolHandler } from '../../providers/protocol-handler'; import { changeProtocolHandler } from '../../providers/protocol-handler';
import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray'; import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray';
import { getMediaIconLocation } from '../utils';
let songControls: ReturnType<typeof getSongControls>; let songControls: ReturnType<typeof getSongControls>;
let savedNotification: Notification | undefined; 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) => { const display = (kind: keyof typeof icons) => {
if (config.get('toastStyle') === ToastStyles.legacy) { if (config.get('toastStyle') === ToastStyles.legacy) {
return `content="${icons[kind]}"`; return `content="${icons[kind]}"`;
@ -163,7 +159,7 @@ const display = (kind: keyof typeof icons) => {
return `\ return `\
content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\ 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 config from './config';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
export default (_win: BrowserWindow, options: ConfigType<'notifications'>) => [ const getMenu = (options: ConfigType<'notifications'>): MenuTemplate => {
...(is.linux() if (is.linux()) {
? [ return [
{ {
label: 'Notification Priority', label: 'Notification Priority',
submenu: urgencyLevels.map((level) => ({ submenu: urgencyLevels.map((level) => ({
@ -19,11 +21,10 @@ export default (_win: BrowserWindow, options: ConfigType<'notifications'>) => [
checked: options.urgency === level.value, checked: options.urgency === level.value,
click: () => config.set('urgency', level.value), click: () => config.set('urgency', level.value),
})), })),
}, }
] ];
: []), } else if (is.windows()) {
...(is.windows() return [
? [
{ {
label: 'Interactive Notifications', label: 'Interactive Notifications',
type: 'checkbox', type: 'checkbox',
@ -59,8 +60,14 @@ export default (_win: BrowserWindow, options: ConfigType<'notifications'>) => [
label: 'Style', label: 'Style',
submenu: getToastStyleMenuItems(options), submenu: getToastStyleMenuItems(options),
}, },
] ];
: []), } else {
return [];
}
};
export default (_win: BrowserWindow, options: ConfigType<'notifications'>): MenuTemplate => [
...getMenu(options),
{ {
label: 'Show notification on unpause', label: 'Show notification on unpause',
type: 'checkbox', type: 'checkbox',
@ -79,8 +86,8 @@ export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
type: 'radio', type: 'radio',
checked: options.toastStyle === index, checked: options.toastStyle === index,
click: () => config.set('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 { cache } from '../../providers/decorators';
import { SongInfo } from '../../providers/song-info'; import { SongInfo } from '../../providers/song-info';
import { getAssetsDirectoryLocation } from '../utils';
const icon = 'assets/youtube-music.png'; const icon = 'assets/youtube-music.png';
const userData = app.getPath('userData'); const userData = app.getPath('userData');
@ -88,10 +89,9 @@ export const saveTempIcon = () => {
continue; 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.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 { app, BrowserWindow, ipcMain } from 'electron';
import { setOptions as setPluginOptions } from '../../config/plugins'; import style from './style.css';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
import { setOptions as setPluginOptions } from '../../config/plugins';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
@ -102,7 +102,7 @@ export default (_win: BrowserWindow, _options: PiPOptions) => {
options ??= _options; options ??= _options;
win ??= _win; win ??= _win;
setLocalOptions({ isInPiP }); setLocalOptions({ isInPiP });
injectCSS(win.webContents, path.join(__dirname, 'style.css')); injectCSS(win.webContents, style);
ipcMain.on('picture-in-picture', () => { ipcMain.on('picture-in-picture', () => {
togglePiP(); togglePiP();
}); });

View File

@ -2,9 +2,11 @@ import { ipcRenderer } from 'electron';
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator'; import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
import keyEventAreEqual from 'keyboardevents-areequal'; import keyEventAreEqual from 'keyboardevents-areequal';
import pipHTML from './templates/picture-in-picture.html';
import { getSongMenu } from '../../providers/dom-elements'; import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromFile, templatePath } from '../utils'; import { ElementFromHtml } from '../utils';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
@ -16,9 +18,7 @@ function $<E extends Element = Element>(selector: string) {
let useNativePiP = false; let useNativePiP = false;
let menu: Element | null = null; let menu: Element | null = null;
const pipButton = ElementFromFile( const pipButton = ElementFromHtml(pipHTML);
templatePath(__dirname, 'picture-in-picture.html'),
);
// Will also clone // Will also clone
function replaceButton(query: string, button: Element) { 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 { getSongMenu } from '../../providers/dom-elements';
import { ElementFromFile, templatePath } from '../utils'; import { ElementFromHtml } from '../utils';
import { singleton } from '../../providers/decorators'; import { singleton } from '../../providers/decorators';
@ -7,7 +9,7 @@ function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector); 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; 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 { globalShortcut, BrowserWindow } from 'electron';
import volumeHudStyle from './volume-hud.css';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
@ -16,7 +16,7 @@ export const enabled = () => isEnabled;
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => { export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => {
isEnabled = true; isEnabled = true;
injectCSS(win.webContents, path.join(__dirname, 'volume-hud.css')); injectCSS(win.webContents, volumeHudStyle);
if (options.globalShortcuts?.volumeUp) { if (options.globalShortcuts?.volumeUp) {
globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true)); globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true));

View File

@ -55,7 +55,7 @@ function firstRun() {
setTooltip(options.savedVolume); setTooltip(options.savedVolume);
if (api.getVolume() !== 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', label: 'Global Hotkeys',
type: 'checkbox', 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), click: (item) => promptGlobalShortcuts(win, options, item),
}, },
{ {

View File

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

View File

@ -1,15 +1,15 @@
import { ipcRenderer } from 'electron'; 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'; import { YoutubePlayer } from '../../types/youtube-player';
function $(selector: string): HTMLElement | null { function $(selector: string): HTMLElement | null {
return document.querySelector(selector); return document.querySelector(selector);
} }
const qualitySettingsButton = ElementFromFile( const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate);
templatePath(__dirname, 'qualitySettingsTemplate.html'),
);
function setup(event: CustomEvent<YoutubePlayer>) { function setup(event: CustomEvent<YoutubePlayer>) {
const api = event.detail; 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 { GetPlayerResponse } from '../../types/get-player-response';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
let videoID: string;
export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => { export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => {
const { apiURL, categories } = { const { apiURL, categories } = {
...defaultConfig.plugins.sponsorblock, ...defaultConfig.plugins.sponsorblock,
@ -19,14 +17,13 @@ export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => {
}; };
ipcMain.on('video-src-changed', async (_, data: GetPlayerResponse) => { ipcMain.on('video-src-changed', async (_, data: GetPlayerResponse) => {
videoID = data?.videoDetails?.videoId; const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
const segments = await fetchSegments(apiURL, categories);
win.webContents.send('sponsorblock-skip', segments); win.webContents.send('sponsorblock-skip', segments);
}); });
}; };
const fetchSegments = async (apiURL: string, categories: string[]) => { const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoID}&categories=${JSON.stringify( const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
categories, categories,
)}`; )}`;
try { try {

View File

@ -4,18 +4,48 @@ import { BrowserWindow, nativeImage } from 'electron';
import getSongControls from '../../providers/song-controls'; import getSongControls from '../../providers/song-controls';
import registerCallback, { SongInfo } from '../../providers/song-info'; import registerCallback, { SongInfo } from '../../providers/song-info';
import { getMediaIconLocation } from '../utils';
let controls: {
playPause: () => void;
next: () => void;
previous: () => void;
};
let currentSongInfo: SongInfo;
export default (win: BrowserWindow) => { export default (win: BrowserWindow) => {
let currentSongInfo: SongInfo;
const { playPause, next, previous } = getSongControls(win); 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) => { registerCallback((songInfo) => {
// Update currentsonginfo for win.on('show') // Update currentsonginfo for win.on('show')
@ -29,39 +59,3 @@ export default (win: BrowserWindow) => {
setThumbar(win, currentSongInfo); 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,65 +3,66 @@ import { TouchBar, NativeImage, BrowserWindow } from 'electron';
import registerCallback from '../../providers/song-info'; import registerCallback from '../../providers/song-info';
import getSongControls from '../../providers/song-controls'; import getSongControls from '../../providers/song-controls';
const {
TouchBarButton,
TouchBarLabel,
TouchBarSpacer,
TouchBarSegmentedControl,
TouchBarScrubber,
} = TouchBar;
// Songtitle label
const songTitle = new TouchBarLabel({
label: '',
});
// This will store the song controls once available
let controls: (() => void)[] = [];
// This will store the song image once available
const songImage: {
icon?: NativeImage;
} = {};
// Pause/play button
const pausePlayButton = new TouchBarButton({});
// The song control buttons (control functions are in the same order)
const buttons = new TouchBarSegmentedControl({
mode: 'buttons',
segments: [
new TouchBarButton({
label: '⏮',
}),
pausePlayButton,
new TouchBarButton({
label: '⏭',
}),
new TouchBarButton({
label: '👎',
}),
new TouchBarButton({
label: '👍',
}),
],
change: (i) => controls[i](),
});
// This is the touchbar object, this combines everything with proper layout
const touchBar = new TouchBar({
items: [
new TouchBarScrubber({
items: [songImage, songTitle],
continuous: false,
}),
new TouchBarSpacer({
size: 'flexible',
}),
buttons,
],
});
export default (win: BrowserWindow) => { export default (win: BrowserWindow) => {
const {
TouchBarButton,
TouchBarLabel,
TouchBarSpacer,
TouchBarSegmentedControl,
TouchBarScrubber,
} = TouchBar;
// Songtitle label
const songTitle = new TouchBarLabel({
label: '',
});
// This will store the song controls once available
let controls: (() => void)[] = [];
// This will store the song image once available
const songImage: {
icon?: NativeImage;
} = {};
// Pause/play button
const pausePlayButton = new TouchBarButton({});
// The song control buttons (control functions are in the same order)
const buttons = new TouchBarSegmentedControl({
mode: 'buttons',
segments: [
new TouchBarButton({
label: '⏮',
}),
pausePlayButton,
new TouchBarButton({
label: '⏭',
}),
new TouchBarButton({
label: '👎',
}),
new TouchBarButton({
label: '👍',
}),
],
change: (i) => controls[i](),
});
// This is the touchbar object, this combines everything with proper layout
const touchBar = new TouchBar({
items: [
new TouchBarScrubber({
items: [songImage, songTitle],
continuous: false,
}),
new TouchBarSpacer({
size: 'flexible',
}),
buttons,
],
});
const { playPause, next, previous, dislike, like } = getSongControls(win); const { playPause, next, previous, dislike, like } = getSongControls(win);
// If the page is ready, register the callback // If the page is ready, register the callback

View File

@ -1,4 +1,5 @@
import { ipcMain, net, BrowserWindow } from 'electron'; import { ipcMain, net, BrowserWindow } from 'electron';
import is from 'electron-is';
import registerCallback from '../../providers/song-info'; import registerCallback from '../../providers/song-info';
@ -41,7 +42,11 @@ const post = (data: Data) => {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ data }), 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) => { export default (win: BrowserWindow) => {

View File

@ -1,9 +1,19 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; 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 { 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 // Creates a DOM element from an HTML string
export const ElementFromHtml = (html: string): HTMLElement => { export const ElementFromHtml = (html: string): HTMLElement => {
@ -46,29 +56,47 @@ export const fileExists = (
}); });
}; };
const cssToInject = new Map(); const cssToInject = new Map<string, (() => void) | undefined>();
export const injectCSS = (webContents: Electron.WebContents, filepath: unknown, cb: (() => void) | undefined = undefined) => { const cssToInjectFile = new Map<string, (() => void) | undefined>();
if (cssToInject.size === 0) { export const injectCSS = (webContents: Electron.WebContents, css: string, cb: (() => void) | undefined = undefined) => {
if (cssToInject.size === 0 && cssToInjectFile.size === 0) {
setupCssInjection(webContents); 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) => { const setupCssInjection = (webContents: Electron.WebContents) => {
webContents.on('did-finish-load', () => { 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')); await webContents.insertCSS(fs.readFileSync(filepath, 'utf8'));
callback?.(); callback?.();
}); });
}); });
}; };
export const getAllPlugins = () => { export const getAvailablePluginNames = () => {
const isDirectory = (source: fs.PathLike) => fs.lstatSync(source).isDirectory(); return Object.keys(defaultConfig.plugins).filter((name) => {
return fs if (is.windows() && name === 'touchbar') {
.readdirSync(__dirname) return false;
.map((name) => path.join(__dirname, name)) } else if (is.macOS() && name === 'taskbar-mediacontrol') {
.filter(isDirectory) return false;
.map((name) => path.basename(name)); } 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 { BrowserWindow } from 'electron';
import forceHideStyle from './force-hide.css';
import buttonSwitcherStyle from './button-switcher.css';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
export default (win: BrowserWindow, options: ConfigType<'video-toggle'>) => { export default (win: BrowserWindow, options: ConfigType<'video-toggle'>) => {
if (options.forceHide) { if (options.forceHide) {
injectCSS(win.webContents, path.join(__dirname, 'force-hide.css')); injectCSS(win.webContents, forceHideStyle);
} else if (!options.mode || options.mode === 'custom') { } 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); background: rgba(33, 33, 33, 0.4);
border-radius: 30px; border-radius: 30px;
overflow: hidden; overflow: hidden;
width: 240px; width: 20rem;
text-align: center; text-align: center;
font-size: 18px; font-size: 18px;
letter-spacing: 1px; letter-spacing: 1px;
color: #fff; color: #fff;
padding-right: 120px; padding-right: 10rem;
position: absolute; position: absolute;
} }
@ -30,7 +30,7 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
width: 120px; width: 10rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -55,7 +55,7 @@
} }
.video-switch-button-checkbox:checked + .video-switch-button-label:before { .video-switch-button-checkbox:checked + .video-switch-button-label:before {
transform: translateX(120px); transform: translateX(10rem);
transition: transform 300ms linear; 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 { setOptions, isEnabled } from '../../config/plugins';
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/front'; import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/front';
@ -19,9 +21,7 @@ let player: HTMLElement & { videoMode_: boolean } | null;
let video: HTMLVideoElement | null; let video: HTMLVideoElement | null;
let api: YoutubePlayer; let api: YoutubePlayer;
const switchButtonDiv = ElementFromFile( const switchButtonDiv = ElementFromHtml(buttonTemplate);
templatePath(__dirname, 'button_template.html'),
);
export default (_options: ConfigType<'video-toggle'>) => { export default (_options: ConfigType<'video-toggle'>) => {
if (_options.forceHide) { if (_options.forceHide) {
@ -56,17 +56,11 @@ function setup(e: CustomEvent<YoutubePlayer>) {
$<HTMLVideoElement>('#player')?.prepend(switchButtonDiv); $<HTMLVideoElement>('#player')?.prepend(switchButtonDiv);
if (options.hideVideo) { setVideoState(!options.hideVideo);
const checkbox = $<HTMLInputElement>('.video-switch-button-checkbox'); forcePlaybackMode();
if (checkbox) { // Fix black video
checkbox.checked = false; if (video) {
} video.style.height = 'auto';
changeDisplay(false);
forcePlaybackMode();
// Fix black video
if (video) {
video.style.height = 'auto';
}
} }
//Prevents bubbling to the player which causes it to stop or resume //Prevents bubbling to the player which causes it to stop or resume
@ -77,9 +71,8 @@ function setup(e: CustomEvent<YoutubePlayer>) {
// Button checked = show video // Button checked = show video
switchButtonDiv.addEventListener('change', (e) => { switchButtonDiv.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
options.hideVideo = target.checked;
changeDisplay(target.checked); setVideoState(target.checked);
setOptions('video-toggle', options);
}); });
video?.addEventListener('srcChanged', videoStarted); 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) { if (player) {
player.style.margin = showVideo ? '' : 'auto 0px'; player.style.margin = showVideo ? '' : 'auto 0px';
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED'); player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
@ -123,7 +122,7 @@ function changeDisplay(showVideo: boolean) {
function videoStarted() { function videoStarted() {
if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') { if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') {
// Video doesn't exist -> switch to song mode // Video doesn't exist -> switch to song mode
changeDisplay(false); setVideoState(false);
// Hide toggle button // Hide toggle button
switchButtonDiv.style.display = 'none'; switchButtonDiv.style.display = 'none';
} else { } else {
@ -137,7 +136,7 @@ function videoStarted() {
switchButtonDiv.style.display = 'initial'; switchButtonDiv.style.display = 'initial';
// Change display to video mode if video exist & video is hidden & option.hideVideo = false // 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') { if (!options.hideVideo && $<HTMLElement>('#song-video.ytmusic-player')?.style.display === 'none') {
changeDisplay(true); setVideoState(true);
} else { } else {
moveVolumeHud(!options.hideVideo); moveVolumeHud(!options.hideVideo);
} }

View File

@ -1,9 +1,9 @@
import path from 'node:path';
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
import emptyPlayerStyle from './empty-player.css';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
export default (win: BrowserWindow) => { 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 { 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 defaultConfig from '../../config/defaults';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
@ -40,7 +37,7 @@ export default (options: ConfigType<'visualizer'>) => {
if (!canvas) { if (!canvas) {
canvas = document.createElement('canvas'); canvas = document.createElement('canvas');
canvas.id = 'visualizer'; canvas.id = 'visualizer';
visualizerContainer.prepend(canvas); visualizerContainer?.prepend(canvas);
} }
const resizeCanvas = () => { const resizeCanvas = () => {

View File

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

View File

@ -8,6 +8,8 @@ import { ConfigType } from '../../../config/dynamic';
const presets = ButterchurnPresets.getPresets(); const presets = ButterchurnPresets.getPresets();
class ButterchurnVisualizer extends Visualizer<Butterchurn> { class ButterchurnVisualizer extends Visualizer<Butterchurn> {
name = 'butterchurn';
visualizer: ReturnType<typeof Butterchurn.createVisualizer>; visualizer: ReturnType<typeof Butterchurn.createVisualizer>;
private readonly renderingFrequencyInMs: number; 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'; import type { ConfigType } from '../../../config/dynamic';
export abstract class Visualizer<T> { export abstract class Visualizer<T> {
/**
* The name must be the same as the file name.
*/
abstract name: string;
abstract visualizer: T; abstract visualizer: T;
protected constructor( protected constructor(

View File

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

View File

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

View File

@ -2,75 +2,106 @@ import { ipcRenderer } from 'electron';
import is from 'electron-is'; import is from 'electron-is';
import config from './config'; import config from './config';
import { fileExists } from './plugins/utils';
import setupSongInfo from './providers/song-info-front'; import setupSongInfo from './providers/song-info-front';
import { setupSongControls } from './providers/song-controls-front'; import { setupSongControls } from './providers/song-controls-front';
import { startingPages } from './providers/extracted-data'; 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); const $ = document.querySelector.bind(document);
let api: Element | null = null; let api: Element | null = null;
interface Actions { enabledPluginNameAndOptions.forEach(async ([plugin, options]) => {
CHANNEL: string; if (Object.hasOwn(preloadPlugins, plugin)) {
ACTIONS: Record<string, string>, const handler = preloadPlugins[plugin];
actions: Record<string, () => void>, try {
} await handler?.();
} catch (error) {
plugins.forEach(async ([plugin, options]) => { console.error(`Error in plugin "${plugin}": ${String(error)}`);
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', () => { document.addEventListener('DOMContentLoaded', () => {
plugins.forEach(async ([plugin, options]) => { enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => {
const pluginPath = await ipcRenderer.invoke( if (Object.hasOwn(rendererPlugins, pluginName)) {
'getPath', const handler = rendererPlugins[pluginName];
__dirname, try {
'plugins', await handler?.(options as never);
plugin, } catch (error) {
'front.js', console.error(`Error in plugin "${pluginName}": ${String(error)}`);
) 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);
});
}); });
// Wait for complete load of youtube api // Wait for complete load of YouTube api
listenForApiLoad(); listenForApiLoad();
// Inject song-info provider // 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 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 promptOptions = {
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,
} : {
customStylesheet: 'dark', customStylesheet: 'dark',
icon: iconPath, 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: 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 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 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). the [`extras` bucket](https://github.com/ScoopInstaller/Extras).
``` ```bash
scoop bucket add extras scoop bucket add extras
scoop install extras/youtube-music 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 true for the manual installation when trying to run the executable(.exe) after a manual download here on github (same
file).* file).*
``` ```bash
winget install th-ch.YouTubeMusic winget install th-ch.YouTubeMusic
``` ```
@ -166,7 +166,7 @@ Some predefined themes are available in https://github.com/kerichdev/themes-for-
## Dev ## Dev
```sh ```bash
git clone https://github.com/th-ch/youtube-music git clone https://github.com/th-ch/youtube-music
cd youtube-music cd youtube-music
npm npm
@ -184,42 +184,68 @@ Using plugins, you can:
Create a folder in `plugins/YOUR-PLUGIN-NAME`: 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 ```typescript
module.exports = win => { // file: back.ts
// win is the BrowserWindow object 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 ```typescript
module.exports = () => { 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 // This function will be called as a preload script
// So you can use front features like `document.querySelector` // 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 ### Common use cases
- injecting custom CSS: create a `style.css` file in the same folder then: - injecting custom CSS: create a `style.css` file in the same folder then:
```node ```typescript
const path = require("path"); import path from 'node:path';
const {injectCSS} = require("../utils"); import { injectCSS } from '../utils';
// back.js // back.ts
module.exports = win => { export default (win: Electron.BrowserWindow) => {
injectCSS(win.webContents, path.join(__dirname, "style.css")); injectCSS(win.webContents, path.join(__dirname, 'style.css'));
}; };
``` ```
- changing the HTML: - changing the HTML:
```node ```typescript
// front.js // front.ts
module.exports = () => { export default () => {
// Remove the login button // Remove the login button
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove(); document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
}; };
@ -243,7 +269,7 @@ using [electron-builder](https://github.com/electron-userland/electron-builder).
## Tests ## Tests
```sh ```bash
npm run test 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 path = require('node:path');
const { _electron: electron } = require('playwright'); 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'); await consentForm.click('button');
} }
const title = await window.title();
expect(title.replaceAll(/\s/g, ' ')).toEqual('YouTube Music');
const url = window.url(); const url = window.url();
expect(url.startsWith('https://music.youtube.com')).toBe(true); 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 config from './config';
import getSongControls from './providers/song-controls'; import getSongControls from './providers/song-controls';
import { getAssetsDirectoryLocation } from './plugins/utils';
import type { MenuTemplate } from './menu'; import type { MenuTemplate } from './menu';
// Prevent tray being garbage collected // 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 { 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({ const trayIcon = nativeImage.createFromPath(iconPath).resize({
width: 16, width: 16,

View File

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

View File

@ -54,3 +54,8 @@ ytmusic-nav-bar > div.left-content > a,
ytmusic-nav-bar > div.left-content > a > picture > img { ytmusic-nav-bar > div.left-content > a > picture > img {
-webkit-user-drag: none; -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;
}