fix: apply fix from eslint

This commit is contained in:
JellyBrick
2024-10-13 22:45:11 +09:00
parent f42f20f770
commit cb1381bbb3
85 changed files with 1858 additions and 1042 deletions

View File

@ -12,7 +12,7 @@ export default tsEslint.config(
tsEslint.configs.eslintRecommended, tsEslint.configs.eslintRecommended,
...tsEslint.configs.recommendedTypeChecked, ...tsEslint.configs.recommendedTypeChecked,
prettier, prettier,
{ ignores: ['dist', 'node_modules', '*.config.*js'] }, { ignores: ['dist', 'node_modules', '*.config.*js', '*.test.*js'] },
{ {
plugins: { plugins: {
stylistic, stylistic,
@ -54,7 +54,7 @@ export default tsEslint.config(
afterLineComment: false, afterLineComment: false,
}], }],
'stylistic/max-len': 'off', 'stylistic/max-len': 'off',
'stylistic/no-mixed-operators': 'error', 'stylistic/no-mixed-operators': 'warn', // prettier does not support no-mixed-operators
'stylistic/no-multi-spaces': ['error', { ignoreEOLComments: true }], 'stylistic/no-multi-spaces': ['error', { ignoreEOLComments: true }],
'stylistic/no-tabs': 'error', 'stylistic/no-tabs': 'error',
'no-void': 'error', 'no-void': 'error',

View File

@ -334,7 +334,9 @@ async function createMainWindow() {
const display = screen.getDisplayNearestPoint(windowPosition); const display = screen.getDisplayNearestPoint(windowPosition);
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
const scaleFactor = is.windows() ? primaryDisplay.scaleFactor / display.scaleFactor : 1; const scaleFactor = is.windows()
? primaryDisplay.scaleFactor / display.scaleFactor
: 1;
const scaledWidth = Math.floor(windowSize.width * scaleFactor); const scaledWidth = Math.floor(windowSize.width * scaleFactor);
const scaledHeight = Math.floor(windowSize.height * scaleFactor); const scaledHeight = Math.floor(windowSize.height * scaleFactor);
@ -342,10 +344,10 @@ async function createMainWindow() {
const scaledY = windowY; const scaledY = windowY;
if ( if (
scaledX + (scaledWidth / 2) < display.bounds.x - 8 || // Left scaledX + scaledWidth / 2 < display.bounds.x - 8 || // Left
scaledX + (scaledWidth / 2) > display.bounds.x + display.bounds.width || // Right scaledX + scaledWidth / 2 > display.bounds.x + display.bounds.width || // Right
scaledY < display.bounds.y - 8 || // Top scaledY < display.bounds.y - 8 || // Top
scaledY + (scaledHeight / 2) > display.bounds.y + display.bounds.height // Bottom scaledY + scaledHeight / 2 > display.bounds.y + display.bounds.height // Bottom
) { ) {
// Window is offscreen // Window is offscreen
if (is.dev()) { if (is.dev()) {
@ -455,7 +457,7 @@ async function createMainWindow() {
event.preventDefault(); event.preventDefault();
win.webContents.loadURL( win.webContents.loadURL(
'https://accounts.google.com/ServiceLogin?ltmpl=music&service=youtube&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26next%3Dhttps%253A%252F%252Fmusic.youtube.com%252F' 'https://accounts.google.com/ServiceLogin?ltmpl=music&service=youtube&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26next%3Dhttps%253A%252F%252Fmusic.youtube.com%252F',
); );
} }
}); });
@ -642,7 +644,9 @@ app.whenReady().then(async () => {
// In dev mode, get string from process.env.VITE_DEV_SERVER_URL, else use fs.readFileSync // In dev mode, get string from process.env.VITE_DEV_SERVER_URL, else use fs.readFileSync
if (is.dev() && process.env.ELECTRON_RENDERER_URL) { if (is.dev() && process.env.ELECTRON_RENDERER_URL) {
// HACK: to make vite work with electron renderer (supports hot reload) // HACK: to make vite work with electron renderer (supports hot reload)
event.returnValue = [null, ` event.returnValue = [
null,
`
console.log('${LoggerPrefix}', 'Loading vite from dev server'); console.log('${LoggerPrefix}', 'Loading vite from dev server');
(async () => { (async () => {
await new Promise((resolve) => { await new Promise((resolve) => {
@ -663,7 +667,8 @@ app.whenReady().then(async () => {
document.body.appendChild(rendererScript); document.body.appendChild(rendererScript);
})(); })();
0 0
`]; `,
];
} else { } else {
const rendererPath = path.join(__dirname, '..', 'renderer'); const rendererPath = path.join(__dirname, '..', 'renderer');
const indexHTML = parse( const indexHTML = parse(
@ -675,7 +680,10 @@ app.whenReady().then(async () => {
scriptSrc.getAttribute('src')!, scriptSrc.getAttribute('src')!,
); );
const scriptString = fs.readFileSync(scriptPath, 'utf-8'); const scriptString = fs.readFileSync(scriptPath, 'utf-8');
event.returnValue = [url.pathToFileURL(scriptPath).toString(), scriptString + ';0']; event.returnValue = [
url.pathToFileURL(scriptPath).toString(),
scriptString + ';0',
];
} }
}); });

View File

@ -34,11 +34,12 @@ const createContext = (
win.webContents.send(event, ...args); win.webContents.send(event, ...args);
}, },
handle: (event: string, listener: CallableFunction) => { handle: (event: string, listener: CallableFunction) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-call
ipcMain.handle(event, (_, ...args: unknown[]) => listener(...args)); ipcMain.handle(event, (_, ...args: unknown[]) => listener(...args));
}, },
on: (event: string, listener: CallableFunction) => { on: (event: string, listener: CallableFunction) => {
ipcMain.on(event, (_, ...args: unknown[]) => { ipcMain.on(event, (_, ...args: unknown[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
listener(...args); listener(...args);
}); });
}, },
@ -75,11 +76,11 @@ export const forceUnloadMainPlugin = async (
); );
return; return;
} else { } else {
console.log( const message = t('common.console.plugins.unload-failed', {
LoggerPrefix, pluginName: id,
t('common.console.plugins.unload-failed', { pluginName: id }), });
); console.log(LoggerPrefix, message);
return Promise.reject(); return Promise.reject(new Error(message));
} }
} catch (err) { } catch (err) {
console.error( console.error(
@ -87,7 +88,7 @@ export const forceUnloadMainPlugin = async (
t('common.console.plugins.unload-failed', { pluginName: id }), t('common.console.plugins.unload-failed', { pluginName: id }),
); );
console.trace(err); console.trace(err);
return Promise.reject(err); return Promise.reject(err as Error);
} }
}; };
@ -111,11 +112,11 @@ export const forceLoadMainPlugin = async (
) { ) {
loadedPluginMap[id] = plugin; loadedPluginMap[id] = plugin;
} else { } else {
console.log( const message = t('common.console.plugins.load-failed', {
LoggerPrefix, pluginName: id,
t('common.console.plugins.load-failed', { pluginName: id }), });
); console.log(LoggerPrefix, message);
return Promise.reject(); return Promise.reject(new Error(message));
} }
} catch (err) { } catch (err) {
console.error( console.error(
@ -123,7 +124,7 @@ export const forceLoadMainPlugin = async (
t('common.console.plugins.initialize-failed', { pluginName: id }), t('common.console.plugins.initialize-failed', { pluginName: id }),
); );
console.trace(err); console.trace(err);
return Promise.reject(err); return Promise.reject(err as Error);
} }
}; };

View File

@ -18,7 +18,8 @@ const loadedPluginMap: Record<
export const createContext = <Config extends PluginConfig>( export const createContext = <Config extends PluginConfig>(
id: string, id: string,
): RendererContext<Config> => ({ ): RendererContext<Config> => ({
getConfig: async () => window.ipcRenderer.invoke('ytmd:get-config', id), getConfig: async () =>
window.ipcRenderer.invoke('ytmd:get-config', id) as Promise<Config>,
setConfig: async (newConfig) => { setConfig: async (newConfig) => {
await window.ipcRenderer.invoke('ytmd:set-config', id, newConfig); await window.ipcRenderer.invoke('ytmd:set-config', id, newConfig);
}, },
@ -30,6 +31,7 @@ export const createContext = <Config extends PluginConfig>(
window.ipcRenderer.invoke(event, ...args), window.ipcRenderer.invoke(event, ...args),
on: (event: string, listener: CallableFunction) => { on: (event: string, listener: CallableFunction) => {
window.ipcRenderer.on(event, (_, ...args: unknown[]) => { window.ipcRenderer.on(event, (_, ...args: unknown[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
listener(...args); listener(...args);
}); });
}, },

View File

@ -1,5 +1,13 @@
import is from 'electron-is'; import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu, MenuItem, shell, } from 'electron'; import {
app,
BrowserWindow,
clipboard,
dialog,
Menu,
MenuItem,
shell,
} from 'electron';
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import { satisfies } from 'semver'; import { satisfies } from 'semver';
@ -68,12 +76,21 @@ export const mainMenuTemplate = async (
const plugin = allPlugins[id]; const plugin = allPlugins[id];
const pluginLabel = plugin?.name?.() ?? id; const pluginLabel = plugin?.name?.() ?? id;
const pluginDescription = plugin?.description?.() ?? undefined; const pluginDescription = plugin?.description?.() ?? undefined;
const isNew = plugin?.addedVersion ? satisfies(packageJson.version, plugin.addedVersion) : false; const isNew = plugin?.addedVersion
? satisfies(packageJson.version, plugin.addedVersion)
: false;
if (!config.plugins.isEnabled(id)) { if (!config.plugins.isEnabled(id)) {
return [ return [
id, id,
pluginEnabledMenu(id, pluginLabel, pluginDescription, isNew, true, innerRefreshMenu), pluginEnabledMenu(
id,
pluginLabel,
pluginDescription,
isNew,
true,
innerRefreshMenu,
),
] as const; ] as const;
} }
@ -115,9 +132,18 @@ export const mainMenuTemplate = async (
const plugin = allPlugins[id]; const plugin = allPlugins[id];
const pluginLabel = plugin?.name?.() ?? id; const pluginLabel = plugin?.name?.() ?? id;
const pluginDescription = plugin?.description?.() ?? undefined; const pluginDescription = plugin?.description?.() ?? undefined;
const isNew = plugin?.addedVersion ? satisfies(packageJson.version, plugin.addedVersion) : false; const isNew = plugin?.addedVersion
? satisfies(packageJson.version, plugin.addedVersion)
: false;
return pluginEnabledMenu(id, pluginLabel, pluginDescription, isNew, true, innerRefreshMenu); return pluginEnabledMenu(
id,
pluginLabel,
pluginDescription,
isNew,
true,
innerRefreshMenu,
);
}); });
const availableLanguages = Object.keys(languageResources); const availableLanguages = Object.keys(languageResources);
@ -233,7 +259,7 @@ export const mainMenuTemplate = async (
label: t( label: t(
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme', 'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme',
), ),
} },
] ]
: []), : []),
...(config.get('options.themes')?.map((theme: string) => ({ ...(config.get('options.themes')?.map((theme: string) => ({
@ -251,16 +277,25 @@ export const mainMenuTemplate = async (
{ theme }, { theme },
), ),
buttons: [ buttons: [
t('main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.cancel'), t(
t('main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.remove'), 'main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.cancel',
),
t(
'main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.remove',
),
], ],
}); });
if (response === 1) { if (response === 1) {
config.set('options.themes', config.get('options.themes')?.filter((t) => t !== theme) ?? []); config.set(
'options.themes',
config
.get('options.themes')
?.filter((t) => t !== theme) ?? [],
);
innerRefreshMenu(); innerRefreshMenu();
} }
} },
})) ?? []), })) ?? []),
{ type: 'separator' }, { type: 'separator' },
{ {

View File

@ -1,5 +1,7 @@
function skipAd(target: Element) { function skipAd(target: Element) {
const skipButton = target.querySelector<HTMLButtonElement>('button.ytp-ad-skip-button-modern'); const skipButton = target.querySelector<HTMLButtonElement>(
'button.ytp-ad-skip-button-modern',
);
if (skipButton) { if (skipButton) {
skipButton.click(); skipButton.click();
} }
@ -17,7 +19,7 @@ function speedUpAndMute(player: Element, isAdShowing: boolean) {
} }
} }
export const loadAdSpeedup = async () => { export const loadAdSpeedup = () => {
const player = document.querySelector<HTMLVideoElement>('#movie_player'); const player = document.querySelector<HTMLVideoElement>('#movie_player');
if (!player) return; if (!player) return;
@ -53,4 +55,4 @@ export const loadAdSpeedup = async () => {
player.classList.contains('ad-interrupting'); player.classList.contains('ad-interrupting');
speedUpAndMute(player, isAdShowing); speedUpAndMute(player, isAdShowing);
skipAd(player); skipAd(player);
} };

View File

@ -79,7 +79,7 @@ export default createPlugin({
if (config.blocker === blockers.AdSpeedup) { if (config.blocker === blockers.AdSpeedup) {
await loadAdSpeedup(); await loadAdSpeedup();
} }
} },
}, },
backend: { backend: {
mainWindow: null as BrowserWindow | null, mainWindow: null as BrowserWindow | null,

View File

@ -104,21 +104,28 @@ export default createPlugin<
buttons.splice(i, 1); buttons.splice(i, 1);
i--; i--;
} else { } else {
(buttons[i].children[0].children[0] as HTMLElement).style.setProperty( (
buttons[i].children[0].children[0] as HTMLElement
).style.setProperty(
'-webkit-mask-size', '-webkit-mask-size',
`100% ${100 - ((count / listsLength) * 100)}%`, `100% ${100 - (count / listsLength) * 100}%`,
); );
} }
i++; i++;
} }
} }
const menuParent = document.querySelector('#action-buttons')?.parentElement; const menuParent =
document.querySelector('#action-buttons')?.parentElement;
if (menuParent && !document.querySelector('.like-menu')) { if (menuParent && !document.querySelector('.like-menu')) {
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.id = 'ytmd-album-action-buttons'; menu.id = 'ytmd-album-action-buttons';
menu.className = 'action-buttons style-scope ytmusic-responsive-header-renderer'; menu.className =
'action-buttons style-scope ytmusic-responsive-header-renderer';
menuParent.insertBefore(menu, menuParent.children[menuParent.children.length - 1]); menuParent.insertBefore(
menu,
menuParent.children[menuParent.children.length - 1],
);
for (const button of buttons) { for (const button of buttons) {
menu.appendChild(button); menu.appendChild(button);
button.addEventListener('click', this.loadFullList); button.addEventListener('click', this.loadFullList);

View File

@ -25,7 +25,12 @@ export default createPlugin<
sidebarSmall: HTMLElement | null; sidebarSmall: HTMLElement | null;
ytmusicAppLayout: HTMLElement | null; ytmusicAppLayout: HTMLElement | null;
getMixedColor(color: string, key: string, alpha?: number, ratioMultiply?: number): string; getMixedColor(
color: string,
key: string,
alpha?: number,
ratioMultiply?: number,
): string;
updateColor(): void; updateColor(): void;
}, },
{ {
@ -91,7 +96,10 @@ export default createPlugin<
this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout'); this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const config = await getConfig(); const config = await getConfig();
document.documentElement.style.setProperty(RATIO_KEY, `${~~(config.ratio * 100)}%`); document.documentElement.style.setProperty(
RATIO_KEY,
`${~~(config.ratio * 100)}%`,
);
}, },
onPlayerApiReady(playerApi) { onPlayerApiReady(playerApi) {
const fastAverageColor = new FastAverageColor(); const fastAverageColor = new FastAverageColor();
@ -100,10 +108,12 @@ export default createPlugin<
if (event.detail.name !== 'dataloaded') return; if (event.detail.name !== 'dataloaded') return;
const playerResponse = playerApi.getPlayerResponse(); const playerResponse = playerApi.getPlayerResponse();
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0); const thumbnail =
playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
if (!thumbnail) return; if (!thumbnail) return;
const albumColor = await fastAverageColor.getColorAsync(thumbnail.url) const albumColor = await fastAverageColor
.getColorAsync(thumbnail.url)
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
return null; return null;
@ -120,8 +130,14 @@ export default createPlugin<
this.darkColor = this.darkColor?.darken(0.05); this.darkColor = this.darkColor?.darken(0.05);
} }
document.documentElement.style.setProperty(COLOR_KEY, `${~~this.color.red()}, ${~~this.color.green()}, ${~~this.color.blue()}`); document.documentElement.style.setProperty(
document.documentElement.style.setProperty(DARK_COLOR_KEY, `${~~this.darkColor.red()}, ${~~this.darkColor.green()}, ${~~this.darkColor.blue()}`); COLOR_KEY,
`${~~this.color.red()}, ${~~this.color.green()}, ${~~this.color.blue()}`,
);
document.documentElement.style.setProperty(
DARK_COLOR_KEY,
`${~~this.darkColor.red()}, ${~~this.darkColor.green()}, ${~~this.darkColor.blue()}`,
);
} else { } else {
document.documentElement.style.setProperty(COLOR_KEY, '0, 0, 0'); document.documentElement.style.setProperty(COLOR_KEY, '0, 0, 0');
document.documentElement.style.setProperty(DARK_COLOR_KEY, '0, 0, 0'); document.documentElement.style.setProperty(DARK_COLOR_KEY, '0, 0, 0');
@ -131,7 +147,10 @@ export default createPlugin<
}); });
}, },
onConfigChange(config) { onConfigChange(config) {
document.documentElement.style.setProperty(RATIO_KEY, `${~~(config.ratio * 100)}%`); document.documentElement.style.setProperty(
RATIO_KEY,
`${~~(config.ratio * 100)}%`,
);
}, },
getMixedColor(color: string, key: string, alpha = 1, ratioMultiply) { getMixedColor(color: string, key: string, alpha = 1, ratioMultiply) {
const keyColor = `rgba(var(${key}), ${alpha})`; const keyColor = `rgba(var(${key}), ${alpha})`;
@ -181,11 +200,23 @@ export default createPlugin<
'--yt-spec-black-1-alpha-95': 'rgba(40,40,40,0.95)', '--yt-spec-black-1-alpha-95': 'rgba(40,40,40,0.95)',
}; };
Object.entries(variableMap).map(([variable, color]) => { Object.entries(variableMap).map(([variable, color]) => {
document.documentElement.style.setProperty(variable, this.getMixedColor(color, COLOR_KEY), 'important'); document.documentElement.style.setProperty(
variable,
this.getMixedColor(color, COLOR_KEY),
'important',
);
}); });
document.body.style.setProperty('background', this.getMixedColor('#030303', COLOR_KEY), 'important'); document.body.style.setProperty(
document.documentElement.style.setProperty('--ytmusic-background', this.getMixedColor('#030303', DARK_COLOR_KEY), 'important'); 'background',
this.getMixedColor('#030303', COLOR_KEY),
'important',
);
document.documentElement.style.setProperty(
'--ytmusic-background',
this.getMixedColor('#030303', DARK_COLOR_KEY),
'important',
);
}, },
}, },
}); });

View File

@ -53,10 +53,16 @@ export default createPlugin({
const songImage = document.querySelector<HTMLImageElement>('#song-image'); const songImage = document.querySelector<HTMLImageElement>('#song-image');
const songVideo = document.querySelector<HTMLDivElement>('#song-video'); const songVideo = document.querySelector<HTMLDivElement>('#song-video');
const image = songImage?.querySelector<HTMLImageElement>('yt-img-shadow > img'); const image = songImage?.querySelector<HTMLImageElement>(
const video = await waitForElement<HTMLVideoElement>('.html5-video-container > video'); 'yt-img-shadow > img',
);
const video = await waitForElement<HTMLVideoElement>(
'.html5-video-container > video',
);
const videoWrapper = document.querySelector('#song-video > .player-wrapper'); const videoWrapper = document.querySelector(
'#song-video > .player-wrapper',
);
const injectBlurImage = () => { const injectBlurImage = () => {
if (!songImage || !image) return null; if (!songImage || !image) return null;
@ -95,7 +101,9 @@ export default createPlugin({
const blurCanvas = document.createElement('canvas'); const blurCanvas = document.createElement('canvas');
blurCanvas.classList.add('html5-blur-canvas'); blurCanvas.classList.add('html5-blur-canvas');
const context = blurCanvas.getContext('2d', { willReadFrequently: true }); const context = blurCanvas.getContext('2d', {
willReadFrequently: true,
});
/* effect */ /* effect */
let lastEffectWorkId: number | null = null; let lastEffectWorkId: number | null = null;
@ -109,14 +117,18 @@ export default createPlugin({
if (!context) return; if (!context) return;
const width = this.qualityRatio; const width = this.qualityRatio;
let height = Math.max(Math.floor((blurCanvas.height / blurCanvas.width) * width), 1,); let height = Math.max(
Math.floor((blurCanvas.height / blurCanvas.width) * width),
1,
);
if (!Number.isFinite(height)) height = width; if (!Number.isFinite(height)) height = width;
if (!height) return; if (!height) return;
context.globalAlpha = 1; context.globalAlpha = 1;
if (lastImageData) { if (lastImageData) {
const frameOffset = (1 / this.buffer) * (1000 / this.interpolationTime); const frameOffset =
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1 (1 / this.buffer) * (1000 / this.interpolationTime);
context.globalAlpha = 1 - frameOffset * 2; // because of alpha value must be < 1
context.putImageData(lastImageData, 0, 0); context.putImageData(lastImageData, 0, 0);
context.globalAlpha = frameOffset; context.globalAlpha = frameOffset;
} }
@ -137,7 +149,9 @@ export default createPlugin({
if (newWidth === 0 || newHeight === 0) return; if (newWidth === 0 || newHeight === 0) return;
blurCanvas.width = this.qualityRatio; blurCanvas.width = this.qualityRatio;
blurCanvas.height = Math.floor((newHeight / newWidth) * this.qualityRatio); blurCanvas.height = Math.floor(
(newHeight / newWidth) * this.qualityRatio,
);
if (this.isFullscreen) blurCanvas.classList.add('fullscreen'); if (this.isFullscreen) blurCanvas.classList.add('fullscreen');
else blurCanvas.classList.remove('fullscreen'); else blurCanvas.classList.remove('fullscreen');
@ -151,7 +165,10 @@ export default createPlugin({
/* hooking */ /* hooking */
let canvasInterval: NodeJS.Timeout | null = null; let canvasInterval: NodeJS.Timeout | null = null;
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / this.buffer))); canvasInterval = setInterval(
onSync,
Math.max(1, Math.ceil(1000 / this.buffer)),
);
const onPause = () => { const onPause = () => {
if (canvasInterval) clearInterval(canvasInterval); if (canvasInterval) clearInterval(canvasInterval);
@ -159,7 +176,10 @@ export default createPlugin({
}; };
const onPlay = () => { const onPlay = () => {
if (canvasInterval) clearInterval(canvasInterval); if (canvasInterval) clearInterval(canvasInterval);
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / this.buffer))); canvasInterval = setInterval(
onSync,
Math.max(1, Math.ceil(1000 / this.buffer)),
);
}; };
songVideo.addEventListener('pause', onPause); songVideo.addEventListener('pause', onPause);
songVideo.addEventListener('play', onPlay); songVideo.addEventListener('play', onPlay);
@ -198,11 +218,20 @@ export default createPlugin({
if (isPageOpen) { if (isPageOpen) {
const isVideo = isVideoMode(); const isVideo = isVideoMode();
if (!force) { if (!force) {
if (this.lastMediaType === 'video' && this.lastVideoSource === video?.src) return false; if (
if (this.lastMediaType === 'image' && this.lastImageSource === image?.src) return false; this.lastMediaType === 'video' &&
this.lastVideoSource === video?.src
)
return false;
if (
this.lastMediaType === 'image' &&
this.lastImageSource === image?.src
)
return false;
} }
this.unregister?.(); this.unregister?.();
this.unregister = (isVideo ? injectBlurVideo() : injectBlurImage()) ?? null; this.unregister =
(isVideo ? injectBlurVideo() : injectBlurImage()) ?? null;
} else { } else {
this.unregister?.(); this.unregister?.();
this.unregister = null; this.unregister = null;

View File

@ -1,14 +1,24 @@
import { t } from "@/i18n"; import { MenuItemConstructorOptions } from 'electron';
import { MenuContext } from "@/types/contexts";
import { MenuItemConstructorOptions } from "electron"; import { t } from '@/i18n';
import { AmbientModePluginConfig } from "./types"; import { MenuContext } from '@/types/contexts';
import { AmbientModePluginConfig } from './types';
export interface menuParameters { export interface menuParameters {
getConfig: () => AmbientModePluginConfig | Promise<AmbientModePluginConfig>; getConfig: () => AmbientModePluginConfig | Promise<AmbientModePluginConfig>;
setConfig: (conf: Partial<Omit<AmbientModePluginConfig, "enabled">>) => void | Promise<void>; setConfig: (
conf: Partial<Omit<AmbientModePluginConfig, 'enabled'>>,
) => void | Promise<void>;
} }
export const menu: (ctx: MenuContext<AmbientModePluginConfig>) => MenuItemConstructorOptions[] | Promise<MenuItemConstructorOptions[]> = async ({ getConfig, setConfig }: menuParameters) => { export const menu: (
ctx: MenuContext<AmbientModePluginConfig>,
) =>
| MenuItemConstructorOptions[]
| Promise<MenuItemConstructorOptions[]> = async ({
getConfig,
setConfig,
}: menuParameters) => {
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000]; const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
const qualityList = [10, 25, 50, 100, 200, 500, 1000]; const qualityList = [10, 25, 50, 100, 200, 500, 1000];
const sizeList = [100, 110, 125, 150, 175, 200, 300]; const sizeList = [100, 110, 125, 150, 175, 200, 300];
@ -107,4 +117,4 @@ export const menu: (ctx: MenuContext<AmbientModePluginConfig>) => MenuItemConstr
}, },
}, },
]; ];
} };

View File

@ -28,7 +28,10 @@ export const backend = createBackend<BackendType, APIServerConfig>({
this.end(); this.end();
}, },
onConfigChange(config) { onConfigChange(config) {
if (this.oldConfig?.hostname === config.hostname && this.oldConfig?.port === config.port) { if (
this.oldConfig?.hostname === config.hostname &&
this.oldConfig?.port === config.port
) {
this.oldConfig = config; this.oldConfig = config;
return; return;
} }
@ -55,7 +58,8 @@ export const backend = createBackend<BackendType, APIServerConfig>({
this.app.use('/api/*', async (ctx, next) => { this.app.use('/api/*', async (ctx, next) => {
const result = await JWTPayloadSchema.spa(await ctx.get('jwtPayload')); const result = await JWTPayloadSchema.spa(await ctx.get('jwtPayload'));
const isAuthorized = result.success && config.authorizedClients.includes(result.data.id); const isAuthorized =
result.success && config.authorizedClients.includes(result.data.id);
if (!isAuthorized) { if (!isAuthorized) {
ctx.status(401); ctx.status(401);
return ctx.body('Unauthorized'); return ctx.body('Unauthorized');

View File

@ -2,9 +2,10 @@ import { createRoute, z } from '@hono/zod-openapi';
import { dialog } from 'electron'; import { dialog } from 'electron';
import { sign } from 'hono/jwt'; import { sign } from 'hono/jwt';
import { t } from '@/i18n';
import { getConnInfo } from '@hono/node-server/conninfo'; import { getConnInfo } from '@hono/node-server/conninfo';
import { t } from '@/i18n';
import { APIServerConfig } from '../../config'; import { APIServerConfig } from '../../config';
import { JWTPayload } from '../scheme'; import { JWTPayload } from '../scheme';
@ -20,7 +21,7 @@ const routes = {
request: { request: {
params: z.object({ params: z.object({
id: z.string(), id: z.string(),
}) }),
}, },
responses: { responses: {
200: { 200: {
@ -40,7 +41,10 @@ const routes = {
}), }),
}; };
export const register = (app: HonoApp, { getConfig, setConfig }: BackendContext<APIServerConfig>) => { export const register = (
app: HonoApp,
{ getConfig, setConfig }: BackendContext<APIServerConfig>,
) => {
app.openapi(routes.request, async (ctx) => { app.openapi(routes.request, async (ctx) => {
const config = await getConfig(); const config = await getConfig();
const { id } = ctx.req.param(); const { id } = ctx.req.param();
@ -54,7 +58,10 @@ export const register = (app: HonoApp, { getConfig, setConfig }: BackendContext<
origin: getConnInfo(ctx).remote.address, origin: getConnInfo(ctx).remote.address,
id, id,
}), }),
buttons: [t('plugins.api-server.dialog.request.buttons.allow'), t('plugins.api-server.dialog.request.deny')], buttons: [
t('plugins.api-server.dialog.request.buttons.allow'),
t('plugins.api-server.dialog.request.deny'),
],
defaultId: 1, defaultId: 1,
cancelId: 1, cancelId: 1,
}); });
@ -68,10 +75,7 @@ export const register = (app: HonoApp, { getConfig, setConfig }: BackendContext<
} }
setConfig({ setConfig({
authorizedClients: [ authorizedClients: [...config.authorizedClients, id],
...config.authorizedClients,
id,
],
}); });
const token = await sign( const token = await sign(

View File

@ -84,7 +84,8 @@ const routes = {
method: 'post', method: 'post',
path: `/api/${API_VERSION}/toggle-play`, path: `/api/${API_VERSION}/toggle-play`,
summary: 'Toggle play/pause', summary: 'Toggle play/pause',
description: 'Change the state of the player to play if paused, or pause if playing', description:
'Change the state of the player to play if paused, or pause if playing',
request: { request: {
headers: AuthHeadersSchema, headers: AuthHeadersSchema,
}, },
@ -280,7 +281,7 @@ const routes = {
schema: z.object({ schema: z.object({
state: z.boolean(), state: z.boolean(),
}), }),
} },
}, },
}, },
}, },
@ -299,7 +300,7 @@ const routes = {
content: { content: {
'application/json': { 'application/json': {
schema: z.object({}), schema: z.object({}),
} },
}, },
}, },
204: { 204: {
@ -321,7 +322,7 @@ const routes = {
content: { content: {
'application/json': { 'application/json': {
schema: SongInfoSchema, schema: SongInfoSchema,
} },
}, },
}, },
204: { 204: {
@ -331,7 +332,11 @@ const routes = {
}), }),
}; };
export const register = (app: HonoApp, { window }: BackendContext<APIServerConfig>, songInfoGetter: () => SongInfo | undefined) => { export const register = (
app: HonoApp,
{ window }: BackendContext<APIServerConfig>,
songInfoGetter: () => SongInfo | undefined,
) => {
const controller = getSongControls(window); const controller = getSongControls(window);
app.openapi(routes.previous, (ctx) => { app.openapi(routes.previous, (ctx) => {
@ -426,9 +431,12 @@ export const register = (app: HonoApp, { window }: BackendContext<APIServerConfi
app.openapi(routes.getFullscreenState, async (ctx) => { app.openapi(routes.getFullscreenState, async (ctx) => {
const stateResponsePromise = new Promise<boolean>((resolve) => { const stateResponsePromise = new Promise<boolean>((resolve) => {
ipcMain.once('ytmd:set-fullscreen', (_, isFullscreen: boolean | undefined) => { ipcMain.once(
'ytmd:set-fullscreen',
(_, isFullscreen: boolean | undefined) => {
return resolve(!!isFullscreen); return resolve(!!isFullscreen);
}); },
);
controller.requestFullscreenInformation(); controller.requestFullscreenInformation();
}); });

View File

@ -5,4 +5,3 @@ export * from './go-forward';
export * from './switch-repeat'; export * from './switch-repeat';
export * from './set-volume'; export * from './set-volume';
export * from './set-fullscreen'; export * from './set-fullscreen';

View File

@ -22,7 +22,8 @@ export const onMenu = async ({
async click() { async click() {
const config = await getConfig(); const config = await getConfig();
const newHostname = await prompt( const newHostname =
(await prompt(
{ {
title: t('plugins.api-server.prompt.hostname.title'), title: t('plugins.api-server.prompt.hostname.title'),
label: t('plugins.api-server.prompt.hostname.label'), label: t('plugins.api-server.prompt.hostname.label'),
@ -32,7 +33,9 @@ export const onMenu = async ({
...promptOptions(), ...promptOptions(),
}, },
window, window,
) ?? (config.hostname ?? defaultAPIServerConfig.hostname); )) ??
config.hostname ??
defaultAPIServerConfig.hostname;
setConfig({ ...config, hostname: newHostname }); setConfig({ ...config, hostname: newHostname });
}, },
@ -43,18 +46,21 @@ export const onMenu = async ({
async click() { async click() {
const config = await getConfig(); const config = await getConfig();
const newPort = await prompt( const newPort =
(await prompt(
{ {
title: t('plugins.api-server.prompt.port.title'), title: t('plugins.api-server.prompt.port.title'),
label: t('plugins.api-server.prompt.port.label'), label: t('plugins.api-server.prompt.port.label'),
value: config.port, value: config.port,
type: 'counter', type: 'counter',
counterOptions: { minimum: 0, maximum: 65565, }, counterOptions: { minimum: 0, maximum: 65565 },
width: 380, width: 380,
...promptOptions(), ...promptOptions(),
}, },
window, window,
) ?? (config.port ?? defaultAPIServerConfig.port); )) ??
config.port ??
defaultAPIServerConfig.port;
setConfig({ ...config, port: newPort }); setConfig({ ...config, port: newPort });
}, },
@ -64,7 +70,9 @@ export const onMenu = async ({
type: 'submenu', type: 'submenu',
submenu: [ submenu: [
{ {
label: t('plugins.api-server.menu.auth-strategy.submenu.auth-at-first.label'), label: t(
'plugins.api-server.menu.auth-strategy.submenu.auth-at-first.label',
),
type: 'radio', type: 'radio',
checked: config.authStrategy === 'AUTH_AT_FIRST', checked: config.authStrategy === 'AUTH_AT_FIRST',
click() { click() {

View File

@ -15,7 +15,10 @@ export default createPlugin({
this.styleSheet = new CSSStyleSheet(); this.styleSheet = new CSSStyleSheet();
await this.styleSheet.replace(style); await this.styleSheet.replace(style);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.styleSheet]; document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
this.styleSheet,
];
}, },
async stop() { async stop() {
await this.styleSheet?.replace(''); await this.styleSheet?.replace('');

View File

@ -34,7 +34,7 @@ export default createPlugin<
{ {
label: t('plugins.captions-selector.menu.autoload'), label: t('plugins.captions-selector.menu.autoload'),
type: 'checkbox', type: 'checkbox',
checked: config.autoload as boolean, checked: config.autoload,
click(item) { click(item) {
setConfig({ autoload: item.checked }); setConfig({ autoload: item.checked });
}, },
@ -42,7 +42,7 @@ export default createPlugin<
{ {
label: t('plugins.captions-selector.menu.disable-captions'), label: t('plugins.captions-selector.menu.disable-captions'),
type: 'checkbox', type: 'checkbox',
checked: config.disableCaptions as boolean, checked: config.disableCaptions,
click(item) { click(item) {
setConfig({ disableCaptions: item.checked }); setConfig({ disableCaptions: item.checked });
}, },

View File

@ -64,7 +64,7 @@ interface VolumeFade {
// Main class // Main class
export class VolumeFader { export class VolumeFader {
private readonly media: HTMLMediaElement; private readonly media: HTMLMediaElement;
private readonly logger: VolumeLogger | false; private readonly logger: VolumeLogger | null;
private scale: { private scale: {
internalToVolume: (level: number) => number; internalToVolume: (level: number) => number;
volumeToInternal: (level: number) => number; volumeToInternal: (level: number) => number;
@ -100,7 +100,7 @@ export class VolumeFader {
this.logger = options.logger; this.logger = options.logger;
} else { } else {
// Set log function explicitly to false // Set log function explicitly to false
this.logger = false; this.logger = null;
} }
// Linear volume fading? // Linear volume fading?
@ -112,7 +112,7 @@ export class VolumeFader {
}; };
// Log setting // Log setting
this.logger && this.logger('Using linear fading.'); this.logger?.('Using linear fading.');
} }
// No linear, but logarithmic fading… // No linear, but logarithmic fading…
else { else {
@ -152,9 +152,8 @@ export class VolumeFader {
}; };
// Log setting if not default // Log setting if not default
options.fadeScaling && if (options.fadeScaling)
this.logger && this.logger?.(
this.logger(
'Using logarithmic fading with ' + 'Using logarithmic fading with ' +
String(10 * dynamicRange) + String(10 * dynamicRange) +
' dB dynamic range.', ' dB dynamic range.',
@ -170,8 +169,7 @@ export class VolumeFader {
this.media.volume = options.initialVolume; this.media.volume = options.initialVolume;
// Log setting // Log setting
this.logger && this.logger?.('Set initial volume to ' + String(this.media.volume) + '.');
this.logger('Set initial volume to ' + String(this.media.volume) + '.');
} }
// Fade duration given? // Fade duration given?
@ -187,7 +185,7 @@ export class VolumeFader {
this.active = false; this.active = false;
// Initialization done // Initialization done
this.logger && this.logger('Initialized for', this.media); this.logger?.('Initialized for', this.media);
} }
/** /**
@ -236,8 +234,7 @@ export class VolumeFader {
this.fadeDuration = fadeDuration; this.fadeDuration = fadeDuration;
// Log setting // Log setting
this.logger && this.logger?.('Set fade duration to ' + String(fadeDuration) + ' ms.');
this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
} else { } else {
// Abort and throw an exception // Abort and throw an exception
throw new TypeError('Positive number expected as fade duration!'); throw new TypeError('Positive number expected as fade duration!');
@ -279,7 +276,7 @@ export class VolumeFader {
this.start(); this.start();
// Log new fade // Log new fade
this.logger && this.logger('New fade started:', this.fade); this.logger?.('New fade started:', this.fade);
// Return instance for chaining // Return instance for chaining
return this; return this;
@ -313,7 +310,7 @@ export class VolumeFader {
// Compute current level on internal scale // Compute current level on internal scale
const level = const level =
(progress * (this.fade.volume.end - this.fade.volume.start)) + progress * (this.fade.volume.end - this.fade.volume.start) +
this.fade.volume.start; this.fade.volume.start;
// Map fade level to volume level and apply it to media element // Map fade level to volume level and apply it to media element
@ -323,8 +320,7 @@ export class VolumeFader {
window.requestAnimationFrame(this.updateVolume.bind(this)); window.requestAnimationFrame(this.updateVolume.bind(this));
} else { } else {
// Log end of fade // Log end of fade
this.logger && this.logger?.('Fade to ' + String(this.fade.volume.end) + ' complete.');
this.logger('Fade to ' + String(this.fade.volume.end) + ' complete.');
// Time is up, jump to target volume // Time is up, jump to target volume
this.media.volume = this.scale.internalToVolume(this.fade.volume.end); this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
@ -333,7 +329,7 @@ export class VolumeFader {
this.active = false; this.active = false;
// Done, call back (if callable) // Done, call back (if callable)
typeof this.fade.callback === 'function' && this.fade.callback(); if (typeof this.fade.callback === 'function') this.fade.callback();
// Clear fade // Clear fade
this.fade = undefined; this.fade = undefined;
@ -382,7 +378,7 @@ export class VolumeFader {
input = Math.log10(input); input = Math.log10(input);
// Scale minus something × 10 dB to 0…1 (clipping at 0) // Scale minus something × 10 dB to 0…1 (clipping at 0)
return Math.max(1 + (input / dynamicRange), 0); return Math.max(1 + input / dynamicRange, 0);
} }
} }

View File

@ -191,7 +191,7 @@ export default createPlugin<
let waitForTransition: Promise<unknown>; let waitForTransition: Promise<unknown>;
const getStreamURL = async (videoID: string): Promise<string> => const getStreamURL = async (videoID: string): Promise<string> =>
this.ipc?.invoke('audio-url', videoID); this.ipc?.invoke('audio-url', videoID) as Promise<string>;
const getVideoIDFromURL = (url: string) => const getVideoIDFromURL = (url: string) =>
new URLSearchParams(url.split('?')?.at(-1)).get('v'); new URLSearchParams(url.split('?')?.at(-1)).get('v');

View File

@ -202,9 +202,9 @@ export const backend = createBackend<
} }
} else if (!config.hideDurationLeft) { } else if (!config.hideDurationLeft) {
// Add the start and end time of the song // Add the start and end time of the song
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000); const songStartTime = Date.now() - (songInfo.elapsedSeconds ?? 0) * 1000;
activityInfo.startTimestamp = songStartTime; activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp = songStartTime + (songInfo.songDuration * 1000); activityInfo.endTimestamp = songStartTime + songInfo.songDuration * 1000;
} }
info.rpc.user?.setActivity(activityInfo).catch(console.error); info.rpc.user?.setActivity(activityInfo).catch(console.error);

View File

@ -183,12 +183,18 @@ function downloadSongOnFinishSetup({
config.downloadOnFinish.mode === 'seconds' && config.downloadOnFinish.mode === 'seconds' &&
duration - time <= config.downloadOnFinish.seconds duration - time <= config.downloadOnFinish.seconds
) { ) {
downloadSong(currentUrl, config.downloadOnFinish.folder ?? config.downloadFolder); downloadSong(
currentUrl,
config.downloadOnFinish.folder ?? config.downloadFolder,
);
} else if ( } else if (
config.downloadOnFinish.mode === 'percent' && config.downloadOnFinish.mode === 'percent' &&
time >= duration * (config.downloadOnFinish.percent / 100) time >= duration * (config.downloadOnFinish.percent / 100)
) { ) {
downloadSong(currentUrl, config.downloadOnFinish.folder ?? config.downloadFolder); downloadSong(
currentUrl,
config.downloadOnFinish.folder ?? config.downloadFolder,
);
} }
} }
@ -438,7 +444,7 @@ async function iterableStreamToProcessedUint8Array(
}), }),
ratio, ratio,
); );
increasePlaylistProgress(0.15 + (ratio * 0.85)); increasePlaylistProgress(0.15 + ratio * 0.85);
}); });
const safeVideoNameWithExtension = `${safeVideoName}.${extension}`; const safeVideoNameWithExtension = `${safeVideoName}.${extension}`;
@ -566,7 +572,13 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
return; return;
} }
if (!playlist || !playlist.items || playlist.items.length === 0 || !playlist.header || !('title' in playlist.header)) { if (
!playlist ||
!playlist.items ||
playlist.items.length === 0 ||
!playlist.header ||
!('title' in playlist.header)
) {
sendError( sendError(
new Error(t('plugins.downloader.backend.feedback.playlist-is-empty')), new Error(t('plugins.downloader.backend.feedback.playlist-is-empty')),
); );
@ -660,7 +672,7 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
const increaseProgress = (itemPercentage: number) => { const increaseProgress = (itemPercentage: number) => {
const currentProgress = (counter - 1) / (items.length ?? 1); const currentProgress = (counter - 1) / (items.length ?? 1);
const newProgress = currentProgress + (progressStep * itemPercentage); const newProgress = currentProgress + progressStep * itemPercentage;
win.setProgressBar(newProgress); win.setProgressBar(newProgress);
}; };

View File

@ -35,7 +35,10 @@ export const onMenu = async ({
click(item) { click(item) {
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
enabled: item.checked, enabled: item.checked,
}, },
}); });
@ -49,14 +52,19 @@ export const onMenu = async ({
click() { click() {
const result = dialog.showOpenDialogSync({ const result = dialog.showOpenDialogSync({
properties: ['openDirectory', 'createDirectory'], properties: ['openDirectory', 'createDirectory'],
defaultPath: getFolder(config.downloadOnFinish?.folder ?? config.downloadFolder), defaultPath: getFolder(
config.downloadOnFinish?.folder ?? config.downloadFolder,
),
}); });
if (result) { if (result) {
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
folder: result[0], folder: result[0],
} },
}); });
} }
}, },
@ -76,7 +84,10 @@ export const onMenu = async ({
click() { click() {
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
mode: 'seconds', mode: 'seconds',
}, },
}); });
@ -91,7 +102,10 @@ export const onMenu = async ({
click() { click() {
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
mode: 'percent', mode: 'percent',
}, },
}); });
@ -120,7 +134,9 @@ export const onMenu = async ({
min: '0', min: '0',
step: '1', step: '1',
}, },
value: config.downloadOnFinish?.seconds ?? defaultConfig.downloadOnFinish!.seconds, value:
config.downloadOnFinish?.seconds ??
defaultConfig.downloadOnFinish!.seconds,
}, },
{ {
label: t( label: t(
@ -133,7 +149,9 @@ export const onMenu = async ({
max: '100', max: '100',
step: '1', step: '1',
}, },
value: config.downloadOnFinish?.percent ?? defaultConfig.downloadOnFinish!.percent, value:
config.downloadOnFinish?.percent ??
defaultConfig.downloadOnFinish!.percent,
}, },
], ],
...promptOptions(), ...promptOptions(),
@ -147,7 +165,10 @@ export const onMenu = async ({
setConfig({ setConfig({
downloadOnFinish: { downloadOnFinish: {
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, ...deepmerge(
defaultConfig.downloadOnFinish,
config.downloadOnFinish,
)!,
seconds: Number(res[0]), seconds: Number(res[0]),
percent: Number(res[1]), percent: Number(res[1]),
}, },

View File

@ -39,7 +39,9 @@ const menuObserver = new MutationObserver(() => {
if (!menuUrl?.includes('watch?')) { if (!menuUrl?.includes('watch?')) {
menuUrl = undefined; menuUrl = undefined;
// check for podcast // check for podcast
for (const it of document.querySelectorAll('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')) { for (const it of document.querySelectorAll(
'tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint',
)) {
if (it.getAttribute('href')?.includes('podcast/')) { if (it.getAttribute('href')?.includes('podcast/')) {
menuUrl = it.getAttribute('href')!; menuUrl = it.getAttribute('href')!;
break; break;
@ -72,7 +74,9 @@ export const onRendererLoad = ({
?.getAttribute('href'); ?.getAttribute('href');
if (!videoUrl && songMenu) { if (!videoUrl && songMenu) {
for (const it of songMenu.querySelectorAll('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')) { for (const it of songMenu.querySelectorAll(
'ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint',
)) {
if (it.getAttribute('href')?.includes('podcast/')) { if (it.getAttribute('href')?.includes('podcast/')) {
videoUrl = it.getAttribute('href'); videoUrl = it.getAttribute('href');
break; break;
@ -86,7 +90,8 @@ export const onRendererLoad = ({
} }
if (videoUrl.startsWith('podcast/')) { if (videoUrl.startsWith('podcast/')) {
videoUrl = defaultConfig.url + '/watch?' + videoUrl.replace('podcast/', 'v='); videoUrl =
defaultConfig.url + '/watch?' + videoUrl.replace('podcast/', 'v=');
} }
if (videoUrl.includes('?playlist=')) { if (videoUrl.includes('?playlist=')) {

View File

@ -4,24 +4,12 @@ export interface InAppMenuConfig {
} }
export const defaultInAppMenuConfig: InAppMenuConfig = { export const defaultInAppMenuConfig: InAppMenuConfig = {
enabled: enabled:
( ((typeof window !== 'undefined' &&
( !window.navigator?.userAgent?.toLowerCase().includes('mac')) ||
typeof window !== 'undefined' && (typeof global !== 'undefined' &&
!window.navigator?.userAgent?.toLowerCase().includes('mac') global.process?.platform !== 'darwin')) &&
) || ((typeof window !== 'undefined' &&
( !window.navigator?.userAgent?.toLowerCase().includes('linux')) ||
typeof global !== 'undefined' && (typeof global !== 'undefined' && global.process?.platform !== 'linux')),
global.process?.platform !== 'darwin'
)
) && (
(
typeof window !== 'undefined' &&
!window.navigator?.userAgent?.toLowerCase().includes('linux')
) ||
(
typeof global !== 'undefined' &&
global.process?.platform !== 'linux'
)
),
hideDOMWindowControls: false, hideDOMWindowControls: false,
}; };

View File

@ -1,6 +1,13 @@
import { register } from 'electron-localshortcut'; import { register } from 'electron-localshortcut';
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron'; import {
BrowserWindow,
Menu,
MenuItem,
ipcMain,
nativeImage,
WebContents,
} from 'electron';
import type { BackendContext } from '@/types/contexts'; import type { BackendContext } from '@/types/contexts';
import type { InAppMenuConfig } from './constants'; import type { InAppMenuConfig } from './constants';
@ -50,11 +57,13 @@ export const onMainLoad = ({
ipcMain.handle('ytmd:menu-event', (event, commandId: number) => { ipcMain.handle('ytmd:menu-event', (event, commandId: number) => {
const target = getMenuItemById(commandId); const target = getMenuItemById(commandId);
if (target) if (target)
target.click( (
undefined, target.click as (
BrowserWindow.fromWebContents(event.sender), args0: unknown,
event.sender, args1: BrowserWindow | null,
); args3: WebContents,
) => void
)(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
}); });
handle('get-menu-by-id', (commandId: number) => { handle('get-menu-by-id', (commandId: number) => {

View File

@ -16,8 +16,9 @@ const isMacOS = navigator.userAgent.includes('Macintosh');
const isNotWindowsOrMacOS = const isNotWindowsOrMacOS =
!navigator.userAgent.includes('Windows') && !isMacOS; !navigator.userAgent.includes('Windows') && !isMacOS;
const [config, setConfig] = createSignal<InAppMenuConfig>(
const [config, setConfig] = createSignal<InAppMenuConfig>(defaultInAppMenuConfig); defaultInAppMenuConfig,
);
export const onRendererLoad = async ({ export const onRendererLoad = async ({
getConfig, getConfig,
ipc, ipc,
@ -29,14 +30,19 @@ export const onRendererLoad = async ({
stylesheet.replaceSync(scrollStyle); stylesheet.replaceSync(scrollStyle);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]; document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet];
render(() => ( render(
() => (
<TitleBar <TitleBar
ipc={ipc} ipc={ipc}
isMacOS={isMacOS} isMacOS={isMacOS}
enableController={isNotWindowsOrMacOS && !config().hideDOMWindowControls} enableController={
isNotWindowsOrMacOS && !config().hideDOMWindowControls
}
initialCollapsed={window.mainConfig.get('options.hideMenu')} initialCollapsed={window.mainConfig.get('options.hideMenu')}
/> />
), document.body); ),
document.body,
);
}; };
export const onPlayerApiReady = () => { export const onPlayerApiReady = () => {

View File

@ -3,7 +3,8 @@ import { css } from 'solid-styled-components';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const iconButton = cacheNoArgs(() => css` const iconButton = cacheNoArgs(
() => css`
-webkit-app-region: none; -webkit-app-region: none;
background: transparent; background: transparent;
@ -32,7 +33,8 @@ const iconButton = cacheNoArgs(() => css`
&:active { &:active {
scale: 0.9; scale: 0.9;
} }
`); `,
);
type CollapseIconButtonProps = JSX.HTMLAttributes<HTMLButtonElement>; type CollapseIconButtonProps = JSX.HTMLAttributes<HTMLButtonElement>;
export const IconButton = (props: CollapseIconButtonProps) => { export const IconButton = (props: CollapseIconButtonProps) => {

View File

@ -3,7 +3,8 @@ import { css } from 'solid-styled-components';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const menuStyle = cacheNoArgs(() => css` const menuStyle = cacheNoArgs(
() => css`
-webkit-app-region: none; -webkit-app-region: none;
display: flex; display: flex;
@ -24,10 +25,11 @@ const menuStyle = cacheNoArgs(() => css`
scale: 0.9; scale: 0.9;
} }
&[data-selected="true"] { &[data-selected='true'] {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
} }
`); `,
);
export type MenuButtonProps = JSX.HTMLAttributes<HTMLLIElement> & { export type MenuButtonProps = JSX.HTMLAttributes<HTMLLIElement> & {
text?: string; text?: string;

View File

@ -2,12 +2,19 @@ import { createSignal, JSX, Show, splitProps } from 'solid-js';
import { mergeProps, Portal } from 'solid-js/web'; import { mergeProps, Portal } from 'solid-js/web';
import { css } from 'solid-styled-components'; import { css } from 'solid-styled-components';
import { Transition } from 'solid-transition-group'; import { Transition } from 'solid-transition-group';
import { autoUpdate, flip, offset, OffsetOptions, size } from '@floating-ui/dom'; import {
autoUpdate,
flip,
offset,
OffsetOptions,
size,
} from '@floating-ui/dom';
import { useFloating } from 'solid-floating-ui'; import { useFloating } from 'solid-floating-ui';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const panelStyle = cacheNoArgs(() => css` const panelStyle = cacheNoArgs(
() => css`
position: fixed; position: fixed;
top: var(--offset-y, 0); top: var(--offset-y, 0);
left: var(--offset-x, 0); left: var(--offset-x, 0);
@ -30,11 +37,13 @@ const panelStyle = cacheNoArgs(() => css`
rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1)
); );
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.05),
0 2px 8px rgba(0, 0, 0, 0.2); 0 2px 8px rgba(0, 0, 0, 0.2);
transform-origin: var(--origin-x, 50%) var(--origin-y, 50%); transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
`); `,
);
const animationStyle = cacheNoArgs(() => ({ const animationStyle = cacheNoArgs(() => ({
enter: css` enter: css`
@ -42,19 +51,23 @@ const animationStyle = cacheNoArgs(() => ({
transform: scale(0.9); transform: scale(0.9);
`, `,
enterActive: css` enterActive: css`
transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1); transition:
opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1),
transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
`, `,
exitTo: css` exitTo: css`
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
`, `,
exitActive: css` exitActive: css`
transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0); transition:
opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0),
transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
`, `,
})); }));
export type Placement = export type Placement =
'top' | 'top'
| 'bottom' | 'bottom'
| 'left' | 'left'
| 'right' | 'right'
@ -92,9 +105,15 @@ export const Panel = (props: PanelProps) => {
size({ size({
padding: 8, padding: 8,
apply({ elements, availableWidth, availableHeight }) { apply({ elements, availableWidth, availableHeight }) {
elements.floating.style.setProperty('--max-width', `${Math.max(200, availableWidth)}px`); elements.floating.style.setProperty(
elements.floating.style.setProperty('--max-height', `${Math.max(200, availableHeight)}px`); '--max-width',
} `${Math.max(200, availableWidth)}px`,
);
elements.floating.style.setProperty(
'--max-height',
`${Math.max(200, availableHeight)}px`,
);
},
}), }),
flip({ fallbackStrategy: 'initialPlacement' }), flip({ fallbackStrategy: 'initialPlacement' }),
], ],
@ -103,7 +122,10 @@ export const Panel = (props: PanelProps) => {
const originX = () => { const originX = () => {
if (position.placement.includes('left')) return '100%'; if (position.placement.includes('left')) return '100%';
if (position.placement.includes('right')) return '0'; if (position.placement.includes('right')) return '0';
if (position.placement.includes('top') || position.placement.includes('bottom')) { if (
position.placement.includes('top') ||
position.placement.includes('bottom')
) {
if (position.placement.includes('start')) return '0'; if (position.placement.includes('start')) return '0';
if (position.placement.includes('end')) return '100%'; if (position.placement.includes('end')) return '100%';
} }
@ -113,7 +135,10 @@ export const Panel = (props: PanelProps) => {
const originY = () => { const originY = () => {
if (position.placement.includes('top')) return '100%'; if (position.placement.includes('top')) return '100%';
if (position.placement.includes('bottom')) return '0'; if (position.placement.includes('bottom')) return '0';
if (position.placement.includes('left') || position.placement.includes('right')) { if (
position.placement.includes('left') ||
position.placement.includes('right')
) {
if (position.placement.includes('start')) return '0'; if (position.placement.includes('start')) return '0';
if (position.placement.includes('end')) return '100%'; if (position.placement.includes('end')) return '100%';
} }

View File

@ -10,7 +10,8 @@ import { autoUpdate, offset, size } from '@floating-ui/dom';
import { Panel } from './Panel'; import { Panel } from './Panel';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const itemStyle = cacheNoArgs(() => css` const itemStyle = cacheNoArgs(
() => css`
position: relative; position: relative;
-webkit-app-region: none; -webkit-app-region: none;
@ -38,27 +39,33 @@ const itemStyle = cacheNoArgs(() => css`
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
} }
&[data-selected="true"] { &[data-selected='true'] {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
} }
& * { & * {
box-sizing: border-box; box-sizing: border-box;
} }
`); `,
);
const itemIconStyle = cacheNoArgs(() => css` const itemIconStyle = cacheNoArgs(
() => css`
height: 32px; height: 32px;
padding: 4px; padding: 4px;
color: white; color: white;
`); `,
);
const itemLabelStyle = cacheNoArgs(() => css` const itemLabelStyle = cacheNoArgs(
() => css`
font-size: 12px; font-size: 12px;
color: white; color: white;
`); `,
);
const itemChipStyle = cacheNoArgs(() => css` const itemChipStyle = cacheNoArgs(
() => css`
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -74,9 +81,11 @@ const itemChipStyle = cacheNoArgs(() => css`
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
`); `,
);
const toolTipStyle = cacheNoArgs(() => css` const toolTipStyle = cacheNoArgs(
() => css`
min-width: 32px; min-width: 32px;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -90,9 +99,11 @@ const toolTipStyle = cacheNoArgs(() => css`
background-color: rgba(25, 25, 25, 0.8); background-color: rgba(25, 25, 25, 0.8);
color: #f1f1f1; color: #f1f1f1;
font-size: 10px; font-size: 10px;
`); `,
);
const popupStyle = cacheNoArgs(() => css` const popupStyle = cacheNoArgs(
() => css`
position: fixed; position: fixed;
top: var(--offset-y, 0); top: var(--offset-y, 0);
left: var(--offset-x, 0); left: var(--offset-x, 0);
@ -102,8 +113,8 @@ const popupStyle = cacheNoArgs(() => css`
z-index: 100000000; z-index: 100000000;
pointer-events: none; pointer-events: none;
`,
`); );
const animationStyle = cacheNoArgs(() => ({ const animationStyle = cacheNoArgs(() => ({
enter: css` enter: css`
@ -111,14 +122,18 @@ const animationStyle = cacheNoArgs(() => ({
transform: scale(0.9); transform: scale(0.9);
`, `,
enterActive: css` enterActive: css`
transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1); transition:
opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1),
transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
`, `,
exitTo: css` exitTo: css`
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
`, `,
exitActive: css` exitActive: css`
transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0); transition:
opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0),
transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
`, `,
})); }));
@ -160,7 +175,11 @@ type CheckboxPanelItemProps = BasePanelItemProps & {
checked: boolean; checked: boolean;
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
}; };
export type PanelItemProps = NormalPanelItemProps | SubmenuItemProps | RadioPanelItemProps | CheckboxPanelItemProps; export type PanelItemProps =
| NormalPanelItemProps
| SubmenuItemProps
| RadioPanelItemProps
| CheckboxPanelItemProps;
export const PanelItem = (props: PanelItemProps) => { export const PanelItem = (props: PanelItemProps) => {
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(false);
const [toolTipOpen, setToolTipOpen] = createSignal(false); const [toolTipOpen, setToolTipOpen] = createSignal(false);
@ -176,17 +195,24 @@ export const PanelItem = (props: PanelItemProps) => {
offset({ mainAxis: 8 }), offset({ mainAxis: 8 }),
size({ size({
apply({ rects, elements }) { apply({ rects, elements }) {
elements.floating.style.setProperty('--max-width', `${rects.reference.width}px`); elements.floating.style.setProperty(
} '--max-width',
`${rects.reference.width}px`,
);
},
}), }),
], ],
}); });
const handleHover = (event: MouseEvent) => { const handleHover = (event: MouseEvent) => {
setToolTipOpen(true); setToolTipOpen(true);
event.target?.addEventListener('mouseleave', () => { event.target?.addEventListener(
'mouseleave',
() => {
setToolTipOpen(false); setToolTipOpen(false);
}, { once: true }); },
{ once: true },
);
if (props.type === 'submenu') { if (props.type === 'submenu') {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -200,21 +226,33 @@ export const PanelItem = (props: PanelItemProps) => {
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove);
event.target?.addEventListener('mouseleave', () => { event.target?.addEventListener(
'mouseleave',
() => {
setTimeout(() => { setTimeout(() => {
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
const parents = getParents(document.elementFromPoint(mouseX, mouseY)); const parents = getParents(
document.elementFromPoint(mouseX, mouseY),
);
if (!parents.includes(child())) { if (!parents.includes(child())) {
setOpen(false); setOpen(false);
} else { } else {
const onOtherHover = (event: MouseEvent) => { const onOtherHover = (event: MouseEvent) => {
const parents = getParents(event.target as HTMLElement); const parents = getParents(event.target as HTMLElement);
const closestLevel = parents.find((it) => it?.dataset?.level)?.dataset.level ?? ''; const closestLevel =
parents.find((it) => it?.dataset?.level)?.dataset.level ??
'';
const path = event.composedPath(); const path = event.composedPath();
const isOtherItem = path.some((it) => it instanceof HTMLElement && it.classList.contains(itemStyle())); const isOtherItem = path.some(
const isChild = closestLevel.startsWith(props.level.join('/')); (it) =>
it instanceof HTMLElement &&
it.classList.contains(itemStyle()),
);
const isChild = closestLevel.startsWith(
props.level.join('/'),
);
if (isOtherItem && !isChild) { if (isOtherItem && !isChild) {
setOpen(false); setOpen(false);
@ -224,12 +262,18 @@ export const PanelItem = (props: PanelItemProps) => {
document.addEventListener('mousemove', onOtherHover); document.addEventListener('mousemove', onOtherHover);
} }
}, 225); }, 225);
}, { once: true }); },
{ once: true },
);
}, 225); }, 225);
event.target?.addEventListener('mouseleave', () => { event.target?.addEventListener(
'mouseleave',
() => {
clearTimeout(timer); clearTimeout(timer);
}, { once: true }); },
{ once: true },
);
} }
}; };
@ -244,7 +288,6 @@ export const PanelItem = (props: PanelItemProps) => {
} }
}; };
return ( return (
<li <li
ref={setAnchor} ref={setAnchor}
@ -255,41 +298,62 @@ export const PanelItem = (props: PanelItemProps) => {
> >
<Switch fallback={<div class={itemIconStyle()} />}> <Switch fallback={<div class={itemIconStyle()} />}>
<Match when={props.type === 'checkbox' && props.checked}> <Match when={props.type === 'checkbox' && props.checked}>
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" <svg
stroke="currentColor" fill="none" class={itemIconStyle()}
stroke-linecap="round" stroke-linejoin="round"> 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 stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12l5 5l10 -10" /> <path d="M5 12l5 5l10 -10" />
</svg> </svg>
</Match> </Match>
<Match when={props.type === 'radio' && props.checked}> <Match when={props.type === 'radio' && props.checked}>
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" <svg
style={{ padding: '6px' }}> class={itemIconStyle()}
<path fill="currentColor" xmlns="http://www.w3.org/2000/svg"
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"/> viewBox="0 0 20 20"
style={{ padding: '6px' }}
>
<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> </svg>
</Match> </Match>
<Match when={props.type === 'radio' && !props.checked}> <Match when={props.type === 'radio' && !props.checked}>
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" <svg
style={{ padding: '6px' }}> class={itemIconStyle()}
<path fill="currentColor" xmlns="http://www.w3.org/2000/svg"
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"/> viewBox="0 0 20 20"
style={{ padding: '6px' }}
>
<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> </svg>
</Match> </Match>
</Switch> </Switch>
<span class={itemLabelStyle()}> <span class={itemLabelStyle()}>{props.name}</span>
{props.name}
</span>
<Show when={props.chip} fallback={<div />}> <Show when={props.chip} fallback={<div />}>
<span class={itemChipStyle()}> <span class={itemChipStyle()}>{props.chip}</span>
{props.chip}
</span>
</Show> </Show>
<Show when={props.type === 'submenu'}> <Show when={props.type === 'submenu'}>
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" <svg
class={itemIconStyle()}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="9 6 15 12 9 18" /> <polyline points="9 6 15 12 9 18" />
</svg> </svg>
@ -322,9 +386,7 @@ export const PanelItem = (props: PanelItemProps) => {
exitActiveClass={animationStyle().exitActive} exitActiveClass={animationStyle().exitActive}
> >
<Show when={toolTipOpen()}> <Show when={toolTipOpen()}>
<div class={toolTipStyle()}> <div class={toolTipStyle()}>{props.toolTip}</div>
{props.toolTip}
</div>
</Show> </Show>
</Transition> </Transition>
</div> </div>

View File

@ -1,5 +1,15 @@
import { Menu, MenuItem } from 'electron'; import { Menu, MenuItem } from 'electron';
import { createEffect, createResource, createSignal, Index, Match, onCleanup, onMount, Show, Switch } from 'solid-js'; import {
createEffect,
createResource,
createSignal,
Index,
Match,
onCleanup,
onMount,
Show,
Switch,
} from 'solid-js';
import { css } from 'solid-styled-components'; import { css } from 'solid-styled-components';
import { TransitionGroup } from 'solid-transition-group'; import { TransitionGroup } from 'solid-transition-group';
@ -14,7 +24,8 @@ import { cacheNoArgs } from '@/providers/decorators';
import type { RendererContext } from '@/types/contexts'; import type { RendererContext } from '@/types/contexts';
import type { InAppMenuConfig } from '../constants'; import type { InAppMenuConfig } from '../constants';
const titleStyle = cacheNoArgs(() => css` const titleStyle = cacheNoArgs(
() => css`
-webkit-app-region: drag; -webkit-app-region: drag;
box-sizing: border-box; box-sizing: border-box;
@ -37,26 +48,31 @@ const titleStyle = cacheNoArgs(() => css`
background-color: var(--titlebar-background-color, #030303); background-color: var(--titlebar-background-color, #030303);
user-select: none; user-select: none;
transition: opacity 200ms ease 0s, transition:
opacity 200ms ease 0s,
transform 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s, transform 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s,
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s; background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
&[data-macos="true"] { &[data-macos='true'] {
padding: 4px 4px 4px 74px; padding: 4px 4px 4px 74px;
} }
ytmusic-app:has(ytmusic-player[player-ui-state=FULLSCREEN]) ~ &:not([data-show="true"]) { ytmusic-app:has(ytmusic-player[player-ui-state='FULLSCREEN'])
~ &:not([data-show='true']) {
transform: translateY(calc(-1 * var(--menu-bar-height, 32px))); transform: translateY(calc(-1 * var(--menu-bar-height, 32px)));
} }
`); `,
);
const separatorStyle = cacheNoArgs(() => css` const separatorStyle = cacheNoArgs(
() => css`
min-height: 1px; min-height: 1px;
height: 1px; height: 1px;
margin: 4px 0; margin: 4px 0;
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
`); `,
);
const animationStyle = cacheNoArgs(() => ({ const animationStyle = cacheNoArgs(() => ({
enter: css` enter: css`
@ -64,14 +80,18 @@ const animationStyle = cacheNoArgs(() => ({
transform: translateX(-50%) scale(0.8); transform: translateX(-50%) scale(0.8);
`, `,
enterActive: css` enterActive: css`
transition: opacity 0.1s cubic-bezier(0.33, 1, 0.68, 1), transform 0.1s cubic-bezier(0.33, 1, 0.68, 1); transition:
opacity 0.1s cubic-bezier(0.33, 1, 0.68, 1),
transform 0.1s cubic-bezier(0.33, 1, 0.68, 1);
`, `,
exitTo: css` exitTo: css`
opacity: 0; opacity: 0;
transform: translateX(-50%) scale(0.8); transform: translateX(-50%) scale(0.8);
`, `,
exitActive: css` exitActive: css`
transition: opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0), transform 0.1s cubic-bezier(0.32, 0, 0.67, 0); transition:
opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0),
transform 0.1s cubic-bezier(0.32, 0, 0.67, 0);
`, `,
move: css` move: css`
transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1); transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1);
@ -89,7 +109,7 @@ export type PanelRendererProps = {
items: Electron.Menu['items']; items: Electron.Menu['items'];
level?: number[]; level?: number[];
onClick?: (commandId: number, radioGroup?: MenuItem[]) => void; onClick?: (commandId: number, radioGroup?: MenuItem[]) => void;
} };
const PanelRenderer = (props: PanelRendererProps) => { const PanelRenderer = (props: PanelRendererProps) => {
const radioGroup = () => props.items.filter((it) => it.type === 'radio'); const radioGroup = () => props.items.filter((it) => it.type === 'radio');
@ -114,12 +134,12 @@ const PanelRenderer = (props: PanelRendererProps) => {
name={subItem().label} name={subItem().label}
chip={subItem().sublabel} chip={subItem().sublabel}
toolTip={subItem().toolTip} toolTip={subItem().toolTip}
level={[...props.level ?? [], subItem().commandId]} level={[...(props.level ?? []), subItem().commandId]}
commandId={subItem().commandId} commandId={subItem().commandId}
> >
<PanelRenderer <PanelRenderer
items={subItem().submenu?.items ?? []} items={subItem().submenu?.items ?? []}
level={[...props.level ?? [], subItem().commandId]} level={[...(props.level ?? []), subItem().commandId]}
onClick={props.onClick} onClick={props.onClick}
/> />
</PanelItem> </PanelItem>
@ -143,7 +163,9 @@ const PanelRenderer = (props: PanelRendererProps) => {
chip={subItem().sublabel} chip={subItem().sublabel}
toolTip={subItem().toolTip} toolTip={subItem().toolTip}
commandId={subItem().commandId} commandId={subItem().commandId}
onChange={() => props.onClick?.(subItem().commandId, radioGroup())} onChange={() =>
props.onClick?.(subItem().commandId, radioGroup())
}
/> />
</Match> </Match>
<Match when={subItem().type === 'separator'}> <Match when={subItem().type === 'separator'}>
@ -169,8 +191,13 @@ export const TitleBar = (props: TitleBarProps) => {
const [menu, setMenu] = createSignal<Menu | null>(null); const [menu, setMenu] = createSignal<Menu | null>(null);
const [mouseY, setMouseY] = createSignal(0); const [mouseY, setMouseY] = createSignal(0);
const [data, { refetch }] = createResource(async () => await props.ipc.invoke('get-menu') as Promise<Menu | null>); const [data, { refetch }] = createResource(
const [isMaximized, { refetch: refetchMaximize }] = createResource(async () => await props.ipc.invoke('window-is-maximized') as Promise<boolean>); async () => (await props.ipc.invoke('get-menu')) as Promise<Menu | null>,
);
const [isMaximized, { refetch: refetchMaximize }] = createResource(
async () =>
(await props.ipc.invoke('window-is-maximized')) as Promise<boolean>,
);
const handleToggleMaximize = async () => { const handleToggleMaximize = async () => {
if (isMaximized()) { if (isMaximized()) {
@ -194,10 +221,12 @@ export const TitleBar = (props: TitleBarProps) => {
)) as MenuItem | null; )) as MenuItem | null;
const newMenu = structuredClone(originalMenu); const newMenu = structuredClone(originalMenu);
const stack = [...newMenu?.items ?? []]; const stack = [...(newMenu?.items ?? [])];
let now: MenuItem | undefined = stack.pop(); let now: MenuItem | undefined = stack.pop();
while (now) { while (now) {
const index = now?.submenu?.items?.findIndex((it) => it.commandId === commandId) ?? -1; const index =
now?.submenu?.items?.findIndex((it) => it.commandId === commandId) ??
-1;
if (index >= 0) { if (index >= 0) {
if (menuItem) now?.submenu?.items?.splice(index, 1, menuItem); if (menuItem) now?.submenu?.items?.splice(index, 1, menuItem);
@ -213,13 +242,16 @@ export const TitleBar = (props: TitleBarProps) => {
return newMenu; return newMenu;
}; };
const handleItemClick = async (commandId: number, radioGroup?: MenuItem[]) => { const handleItemClick = async (
commandId: number,
radioGroup?: MenuItem[],
) => {
const menuData = menu(); const menuData = menu();
if (!menuData) return; if (!menuData) return;
if (Array.isArray(radioGroup)) { if (Array.isArray(radioGroup)) {
let newMenu = menuData; let newMenu = menuData;
for await (const item of radioGroup) { for (const item of radioGroup) {
newMenu = await refreshMenuItem(newMenu, item.commandId); newMenu = await refreshMenuItem(newMenu, item.commandId);
} }
@ -275,15 +307,12 @@ export const TitleBar = (props: TitleBarProps) => {
const scrollValue = ytmusicAppLayout.scrollTop; const scrollValue = ytmusicAppLayout.scrollTop;
if (scrollValue > 20) { if (scrollValue > 20) {
ytmusicAppLayout.classList.add('content-scrolled'); ytmusicAppLayout.classList.add('content-scrolled');
} } else {
else{
ytmusicAppLayout.classList.remove('content-scrolled'); ytmusicAppLayout.classList.remove('content-scrolled');
} }
}); });
}); });
createEffect(() => { createEffect(() => {
if (!menu() && data()) { if (!menu() && data()) {
setMenu(data() ?? null); setMenu(data() ?? null);
@ -295,7 +324,12 @@ export const TitleBar = (props: TitleBarProps) => {
}); });
return ( return (
<nav data-ytmd-main-panel={true} class={titleStyle()} data-macos={props.isMacOS} data-show={mouseY() < 32}> <nav
data-ytmd-main-panel={true}
class={titleStyle()}
data-macos={props.isMacOS}
data-show={mouseY() < 32}
>
<IconButton <IconButton
onClick={() => setCollapsed(!collapsed())} onClick={() => setCollapsed(!collapsed())}
style={{ style={{
@ -310,15 +344,34 @@ export const TitleBar = (props: TitleBarProps) => {
</svg> </svg>
</IconButton> </IconButton>
<TransitionGroup <TransitionGroup
enterClass={ignoreTransition() ? animationStyle().fakeTarget : animationStyle().enter} enterClass={
enterActiveClass={ignoreTransition() ? animationStyle().fake : animationStyle().enterActive} ignoreTransition()
exitToClass={ignoreTransition() ? animationStyle().fakeTarget : animationStyle().exitTo} ? animationStyle().fakeTarget
exitActiveClass={ignoreTransition() ? animationStyle().fake : animationStyle().exitActive} : animationStyle().enter
}
enterActiveClass={
ignoreTransition()
? animationStyle().fake
: animationStyle().enterActive
}
exitToClass={
ignoreTransition()
? animationStyle().fakeTarget
: animationStyle().exitTo
}
exitActiveClass={
ignoreTransition()
? animationStyle().fake
: animationStyle().exitActive
}
onBeforeEnter={(element) => { onBeforeEnter={(element) => {
if (ignoreTransition()) return; if (ignoreTransition()) return;
const index = Number(element.getAttribute('data-index') ?? 0); const index = Number(element.getAttribute('data-index') ?? 0);
(element as HTMLElement).style.setProperty('transition-delay', `${(index * 0.025)}s`); (element as HTMLElement).style.setProperty(
'transition-delay',
`${index * 0.025}s`,
);
}} }}
onAfterEnter={(element) => { onAfterEnter={(element) => {
(element as HTMLElement).style.removeProperty('transition-delay'); (element as HTMLElement).style.removeProperty('transition-delay');
@ -328,13 +381,18 @@ export const TitleBar = (props: TitleBarProps) => {
const index = Number(element.getAttribute('data-index') ?? 0); const index = Number(element.getAttribute('data-index') ?? 0);
const length = Number(element.getAttribute('data-length') ?? 1); const length = Number(element.getAttribute('data-length') ?? 1);
(element as HTMLElement).style.setProperty('transition-delay', `${(length * 0.025) - (index * 0.025)}s`); (element as HTMLElement).style.setProperty(
'transition-delay',
`${length * 0.025 - index * 0.025}s`,
);
}} }}
> >
<Show when={!collapsed()}> <Show when={!collapsed()}>
<Index each={menu()?.items}> <Index each={menu()?.items}>
{(item, index) => { {(item, index) => {
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null); const [anchor, setAnchor] = createSignal<HTMLElement | null>(
null,
);
const handleClick = () => { const handleClick = () => {
if (openTarget() === anchor()) { if (openTarget() === anchor()) {
@ -383,4 +441,3 @@ export const TitleBar = (props: TitleBarProps) => {
</nav> </nav>
); );
}; };

View File

@ -4,7 +4,8 @@ import { Show } from 'solid-js';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { cacheNoArgs } from '@/providers/decorators'; import { cacheNoArgs } from '@/providers/decorators';
const containerStyle = cacheNoArgs(() => css` const containerStyle = cacheNoArgs(
() => css`
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
@ -16,7 +17,8 @@ const containerStyle = cacheNoArgs(() => css`
background: rgba(255, 0, 0, 0.5); background: rgba(255, 0, 0, 0.5);
} }
} }
`); `,
);
export type WindowControllerProps = { export type WindowControllerProps = {
isMaximize?: boolean; isMaximize?: boolean;
@ -24,20 +26,35 @@ export type WindowControllerProps = {
onToggleMaximize?: () => void; onToggleMaximize?: () => void;
onMinimize?: () => void; onMinimize?: () => void;
onClose?: () => void; onClose?: () => void;
} };
export const WindowController = (props: WindowControllerProps) => { export const WindowController = (props: WindowControllerProps) => {
return ( return (
<div class={containerStyle()}> <div class={containerStyle()}>
<IconButton onClick={props.onMinimize}> <IconButton onClick={props.onMinimize}>
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
<path fill="currentColor" d="M3.755 12.5h16.492a.75.75 0 0 0 0-1.5H3.755a.75.75 0 0 0 0 1.5Z"/> width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M3.755 12.5h16.492a.75.75 0 0 0 0-1.5H3.755a.75.75 0 0 0 0 1.5Z"
/>
</svg> </svg>
</IconButton> </IconButton>
<IconButton onClick={props.onToggleMaximize}> <IconButton onClick={props.onToggleMaximize}>
<Show <Show
when={props.isMaximize} when={props.isMaximize}
fallback={ fallback={
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
fill="currentColor" fill="currentColor"
d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H6Z" d="M6 3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H6Z"
@ -45,7 +62,13 @@ export const WindowController = (props: WindowControllerProps) => {
</svg> </svg>
} }
> >
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
fill="currentColor" fill="currentColor"
d="M7.518 5H6.009a3.25 3.25 0 0 1 3.24-3h8.001A4.75 4.75 0 0 1 22 6.75v8a3.25 3.25 0 0 1-3 3.24v-1.508a1.75 1.75 0 0 0 1.5-1.732v-8a3.25 3.25 0 0 0-3.25-3.25h-8A1.75 1.75 0 0 0 7.518 5ZM5.25 6A3.25 3.25 0 0 0 2 9.25v9.5A3.25 3.25 0 0 0 5.25 22h9.5A3.25 3.25 0 0 0 18 18.75v-9.5A3.25 3.25 0 0 0 14.75 6h-9.5ZM3.5 9.25c0-.966.784-1.75 1.75-1.75h9.5c.967 0 1.75.784 1.75 1.75v9.5a1.75 1.75 0 0 1-1.75 1.75h-9.5a1.75 1.75 0 0 1-1.75-1.75v-9.5Z" d="M7.518 5H6.009a3.25 3.25 0 0 1 3.24-3h8.001A4.75 4.75 0 0 1 22 6.75v8a3.25 3.25 0 0 1-3 3.24v-1.508a1.75 1.75 0 0 0 1.5-1.732v-8a3.25 3.25 0 0 0-3.25-3.25h-8A1.75 1.75 0 0 0 7.518 5ZM5.25 6A3.25 3.25 0 0 0 2 9.25v9.5A3.25 3.25 0 0 0 5.25 22h9.5A3.25 3.25 0 0 0 18 18.75v-9.5A3.25 3.25 0 0 0 14.75 6h-9.5ZM3.5 9.25c0-.966.784-1.75 1.75-1.75h9.5c.967 0 1.75.784 1.75 1.75v9.5a1.75 1.75 0 0 1-1.75 1.75h-9.5a1.75 1.75 0 0 1-1.75-1.75v-9.5Z"
@ -54,7 +77,13 @@ export const WindowController = (props: WindowControllerProps) => {
</Show> </Show>
</IconButton> </IconButton>
<IconButton onClick={props.onClose}> <IconButton onClick={props.onClose}>
<svg width={16} height={16} fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
width={16}
height={16}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
fill="currentColor" fill="currentColor"
d="m4.21 4.387.083-.094a1 1 0 0 1 1.32-.083l.094.083L12 10.585l6.293-6.292a1 1 0 1 1 1.414 1.414L13.415 12l6.292 6.293a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.32.083l-.094-.083L12 13.415l-6.293 6.292a1 1 0 0 1-1.414-1.414L10.585 12 4.293 5.707a1 1 0 0 1-.083-1.32l.083-.094-.083.094Z" d="m4.21 4.387.083-.094a1 1 0 0 1 1.32-.083l.094.083L12 10.585l6.293-6.292a1 1 0 1 1 1.414 1.414L13.415 12l6.292 6.293a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.32.083l-.094-.083L12 13.415l-6.293 6.292a1 1 0 0 1-1.414-1.414L10.585 12 4.293 5.707a1 1 0 0 1-.083-1.32l.083-.094-.083.094Z"

View File

@ -3,13 +3,15 @@ import { DataConnection, Peer } from 'peerjs';
import type { Permission, Profile, VideoData } from './types'; import type { Permission, Profile, VideoData } from './types';
export type ConnectionEventMap = { export type ConnectionEventMap = {
ADD_SONGS: { videoList: VideoData[], index?: number }; ADD_SONGS: { videoList: VideoData[]; index?: number };
REMOVE_SONG: { index: number }; REMOVE_SONG: { index: number };
MOVE_SONG: { fromIndex: number; toIndex: number }; MOVE_SONG: { fromIndex: number; toIndex: number };
IDENTIFY: { profile: Profile } | undefined; IDENTIFY: { profile: Profile } | undefined;
SYNC_PROFILE: { profiles: Record<string, Profile> } | undefined; SYNC_PROFILE: { profiles: Record<string, Profile> } | undefined;
SYNC_QUEUE: { videoList: VideoData[] } | undefined; SYNC_QUEUE: { videoList: VideoData[] } | undefined;
SYNC_PROGRESS: { progress?: number; state?: number; index?: number; } | undefined; SYNC_PROGRESS:
| { progress?: number; state?: number; index?: number }
| undefined;
PERMISSION: Permission | undefined; PERMISSION: Permission | undefined;
}; };
export type ConnectionEventUnion = { export type ConnectionEventUnion = {
@ -24,9 +26,12 @@ type PromiseUtil<T> = {
promise: Promise<T>; promise: Promise<T>;
resolve: (id: T) => void; resolve: (id: T) => void;
reject: (err: unknown) => void; reject: (err: unknown) => void;
} };
export type ConnectionListener = (event: ConnectionEventUnion, conn: DataConnection) => void; export type ConnectionListener = (
event: ConnectionEventUnion,
conn: DataConnection,
) => void;
export type ConnectionMode = 'host' | 'guest' | 'disconnected'; export type ConnectionMode = 'host' | 'guest' | 'disconnected';
export class Connection { export class Connection {
private peer: Peer; private peer: Peer;
@ -95,9 +100,12 @@ export class Connection {
return Object.values(this.connections); return Object.values(this.connections);
} }
public async broadcast<Event extends keyof ConnectionEventMap>(type: Event, payload: ConnectionEventMap[Event]) { public async broadcast<Event extends keyof ConnectionEventMap>(
type: Event,
payload: ConnectionEventMap[Event],
) {
await Promise.all( await Promise.all(
this.getConnections().map((conn) => conn.send({ type, payload })) this.getConnections().map((conn) => conn.send({ type, payload })),
); );
} }
@ -125,7 +133,13 @@ export class Connection {
this.connectionListeners.forEach((listener) => listener(conn)); this.connectionListeners.forEach((listener) => listener(conn));
conn.on('data', (data) => { conn.on('data', (data) => {
if (!data || typeof data !== 'object' || !('type' in data) || !('payload' in data) || !data.type) { if (
!data ||
typeof data !== 'object' ||
!('type' in data) ||
!('payload' in data) ||
!data.type
) {
console.warn('Music Together: Invalid data', data); console.warn('Music Together: Invalid data', data);
return; return;
} }

View File

@ -4,7 +4,7 @@ import itemHTML from './templates/item.html?raw';
import popupHTML from './templates/popup.html?raw'; import popupHTML from './templates/popup.html?raw';
type Placement = type Placement =
'top' | 'top'
| 'bottom' | 'bottom'
| 'right' | 'right'
| 'left' | 'left'
@ -15,29 +15,37 @@ type Placement =
| 'top-right' | 'top-right'
| 'bottom-left' | 'bottom-left'
| 'bottom-right'; | 'bottom-right';
type PopupItem = (ItemRendererProps & { type: 'item'; }) type PopupItem =
| { type: 'divider'; } | (ItemRendererProps & { type: 'item' })
| { type: 'custom'; element: HTMLElement; }; | { type: 'divider' }
| { type: 'custom'; element: HTMLElement };
type PopupProps = { type PopupProps = {
data: PopupItem[]; data: PopupItem[];
anchorAt?: Placement; anchorAt?: Placement;
popupAt?: Placement; popupAt?: Placement;
} };
export const Popup = (props: PopupProps) => { export const Popup = (props: PopupProps) => {
const popup = ElementFromHtml(popupHTML); const popup = ElementFromHtml(popupHTML);
const container = popup.querySelector<HTMLElement>('.music-together-popup-container')!; const container = popup.querySelector<HTMLElement>(
'.music-together-popup-container',
)!;
const items = props.data const items = props.data
.map((props) => { .map((props) => {
if (props.type === 'item') return { if (props.type === 'item')
return {
type: 'item' as const, type: 'item' as const,
...ItemRenderer(props), ...ItemRenderer(props),
}; };
if (props.type === 'divider') return { if (props.type === 'divider')
return {
type: 'divider' as const, type: 'divider' as const,
element: ElementFromHtml('<div class="music-together-divider horizontal"></div>'), element: ElementFromHtml(
'<div class="music-together-divider horizontal"></div>',
),
}; };
if (props.type === 'custom') return { if (props.type === 'custom')
return {
type: 'custom' as const, type: 'custom' as const,
element: props.element, element: props.element,
}; };
@ -80,7 +88,9 @@ export const Popup = (props: PopupProps) => {
setTimeout(() => { setTimeout(() => {
const onClose = (event: MouseEvent) => { const onClose = (event: MouseEvent) => {
const isPopupClick = event.composedPath().some((element) => element === popup); const isPopupClick = event
.composedPath()
.some((element) => element === popup);
if (!isPopupClick) { if (!isPopupClick) {
this.dismiss(); this.dismiss();
document.removeEventListener('click', onClose); document.removeEventListener('click', onClose);
@ -101,7 +111,7 @@ export const Popup = (props: PopupProps) => {
dismiss() { dismiss() {
popup.style.setProperty('opacity', '0'); popup.style.setProperty('opacity', '0');
popup.style.setProperty('pointer-events', 'none'); popup.style.setProperty('pointer-events', 'none');
} },
}; };
}; };
@ -133,6 +143,6 @@ export const ItemRenderer = (props: ItemRendererProps) => {
setText(text: string) { setText(text: string) {
textContainer.replaceChildren(text); textContainer.replaceChildren(text);
}, },
id: props.id id: props.id,
}; };
}; };

View File

@ -6,7 +6,12 @@ import { t } from '@/i18n';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import promptOptions from '@/providers/prompt-options'; import promptOptions from '@/providers/prompt-options';
import { getDefaultProfile, type Permission, type Profile, type VideoData } from './types'; import {
getDefaultProfile,
type Permission,
type Profile,
type VideoData,
} from './types';
import { Queue } from './queue'; import { Queue } from './queue';
import { Connection, type ConnectionEventUnion } from './connection'; import { Connection, type ConnectionEventUnion } from './connection';
import { createHostPopup } from './ui/host'; import { createHostPopup } from './ui/host';
@ -26,7 +31,7 @@ type RawAccountData = {
runs: { text: string }[]; runs: { text: string }[];
}; };
accountPhoto: { accountPhoto: {
thumbnails: { url: string; width: number; height: number; }[]; thumbnails: { url: string; width: number; height: number }[];
}; };
settingsEndpoint: unknown; settingsEndpoint: unknown;
manageAccountTitle: unknown; manageAccountTitle: unknown;
@ -59,7 +64,7 @@ export default createPlugin<
stateInterval?: number; stateInterval?: number;
updateNext: boolean; updateNext: boolean;
ignoreChange: boolean; ignoreChange: boolean;
rollbackInjector?: (() => void); rollbackInjector?: () => void;
me?: Omit<Profile, 'id'>; me?: Omit<Profile, 'id'>;
profiles: Record<string, Profile>; profiles: Record<string, Profile>;
permission: Permission; permission: Permission;
@ -79,16 +84,18 @@ export default createPlugin<
restartNeeded: false, restartNeeded: false,
addedVersion: '3.2.X', addedVersion: '3.2.X',
config: { config: {
enabled: false enabled: false,
}, },
stylesheets: [style], stylesheets: [style],
backend({ ipc }) { backend({ ipc }) {
ipc.handle('music-together:prompt', async (title: string, label: string) => prompt({ ipc.handle('music-together:prompt', async (title: string, label: string) =>
prompt({
title, title,
label, label,
type: 'input', type: 'input',
...promptOptions() ...promptOptions(),
})); }),
);
}, },
renderer: { renderer: {
updateNext: false, updateNext: false,
@ -112,15 +119,19 @@ export default createPlugin<
videoChangeListener(event: CustomEvent<VideoDataChanged>) { videoChangeListener(event: CustomEvent<VideoDataChanged>) {
if (event.detail.name === 'dataloaded' || this.updateNext) { if (event.detail.name === 'dataloaded' || this.updateNext) {
if (this.connection?.mode === 'host') { if (this.connection?.mode === 'host') {
const videoList: VideoData[] = this.queue?.flatItems.map((it) => ({ const videoList: VideoData[] =
this.queue?.flatItems.map(
(it) =>
({
videoId: it!.videoId, videoId: it!.videoId,
ownerId: this.connection!.id ownerId: this.connection!.id,
} satisfies VideoData)) ?? []; }) satisfies VideoData,
) ?? [];
this.queue?.setVideoList(videoList, false); this.queue?.setVideoList(videoList, false);
this.queue?.syncQueueOwner(); this.queue?.syncQueueOwner();
this.connection.broadcast('SYNC_QUEUE', { this.connection.broadcast('SYNC_QUEUE', {
videoList videoList,
}); });
this.updateNext = event.detail.name === 'dataloaded'; this.updateNext = event.detail.name === 'dataloaded';
@ -138,7 +149,7 @@ export default createPlugin<
this.connection.broadcast('SYNC_PROGRESS', { this.connection.broadcast('SYNC_PROGRESS', {
// progress: this.playerApi?.getCurrentTime(), // progress: this.playerApi?.getCurrentTime(),
state: this.playerApi?.getPlayerState() state: this.playerApi?.getPlayerState(),
// index: this.queue?.selectedIndex ?? 0, // index: this.queue?.selectedIndex ?? 0,
}); });
}, },
@ -150,13 +161,17 @@ export default createPlugin<
if (!wait) return false; if (!wait) return false;
if (!this.me) this.me = getDefaultProfile(this.connection.id); if (!this.me) this.me = getDefaultProfile(this.connection.id);
const rawItems = this.queue?.flatItems?.map((it) => ({ const rawItems =
this.queue?.flatItems?.map(
(it) =>
({
videoId: it!.videoId, videoId: it!.videoId,
ownerId: this.connection!.id ownerId: this.connection!.id,
} satisfies VideoData)) ?? []; }) satisfies VideoData,
) ?? [];
this.queue?.setOwner({ this.queue?.setOwner({
id: this.connection.id, id: this.connection.id,
...this.me ...this.me,
}); });
this.queue?.setVideoList(rawItems, false); this.queue?.setVideoList(rawItems, false);
this.queue?.syncQueueOwner(); this.queue?.syncQueueOwner();
@ -166,31 +181,41 @@ export default createPlugin<
this.profiles = {}; this.profiles = {};
this.connection.onConnections((connection) => { this.connection.onConnections((connection) => {
if (!connection) { if (!connection) {
this.api?.toastService?.show(t('plugins.music-together.toast.disconnected')); this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
this.onStop(); this.onStop();
return; return;
} }
if (!connection.open) { if (!connection.open) {
this.api?.toastService?.show(t('plugins.music-together.toast.user-disconnected', { this.api?.toastService?.show(
name: this.profiles[connection.peer]?.name t('plugins.music-together.toast.user-disconnected', {
})); name: this.profiles[connection.peer]?.name,
}),
);
this.putProfile(connection.peer, undefined); this.putProfile(connection.peer, undefined);
} }
}); });
this.putProfile(this.connection.id, { this.putProfile(this.connection.id, {
id: this.connection.id, id: this.connection.id,
...this.me ...this.me,
}); });
const listener = async (event: ConnectionEventUnion, conn?: DataConnection) => { const listener = async (
event: ConnectionEventUnion,
conn?: DataConnection,
) => {
this.ignoreChange = true; this.ignoreChange = true;
switch (event.type) { switch (event.type) {
case 'ADD_SONGS': { case 'ADD_SONGS': {
if (conn && this.permission === 'host-only') return; if (conn && this.permission === 'host-only') return;
await this.queue?.addVideos(event.payload.videoList, event.payload.index); await this.queue?.addVideos(
event.payload.videoList,
event.payload.index,
);
await this.connection?.broadcast('ADD_SONGS', event.payload); await this.connection?.broadcast('ADD_SONGS', event.payload);
break; break;
} }
@ -204,27 +229,38 @@ export default createPlugin<
case 'MOVE_SONG': { case 'MOVE_SONG': {
if (conn && this.permission === 'host-only') { if (conn && this.permission === 'host-only') {
await this.connection?.broadcast('SYNC_QUEUE', { await this.connection?.broadcast('SYNC_QUEUE', {
videoList: this.queue?.videoList ?? [] videoList: this.queue?.videoList ?? [],
}); });
break; break;
} }
this.queue?.moveItem(event.payload.fromIndex, event.payload.toIndex); this.queue?.moveItem(
event.payload.fromIndex,
event.payload.toIndex,
);
await this.connection?.broadcast('MOVE_SONG', event.payload); await this.connection?.broadcast('MOVE_SONG', event.payload);
break; break;
} }
case 'IDENTIFY': { case 'IDENTIFY': {
if (!event.payload || !conn) { if (!event.payload || !conn) {
console.warn('Music Together [Host]: Received "IDENTIFY" event without payload or connection'); console.warn(
'Music Together [Host]: Received "IDENTIFY" event without payload or connection',
);
break; break;
} }
this.api?.toastService?.show(t('plugins.music-together.toast.user-connected', { name: event.payload.profile.name })); this.api?.toastService?.show(
t('plugins.music-together.toast.user-connected', {
name: event.payload.profile.name,
}),
);
this.putProfile(conn.peer, event.payload.profile); this.putProfile(conn.peer, event.payload.profile);
break; break;
} }
case 'SYNC_PROFILE': { case 'SYNC_PROFILE': {
await this.connection?.broadcast('SYNC_PROFILE', { profiles: this.profiles }); await this.connection?.broadcast('SYNC_PROFILE', {
profiles: this.profiles,
});
break; break;
} }
@ -237,7 +273,7 @@ export default createPlugin<
} }
case 'SYNC_QUEUE': { case 'SYNC_QUEUE': {
await this.connection?.broadcast('SYNC_QUEUE', { await this.connection?.broadcast('SYNC_QUEUE', {
videoList: this.queue?.videoList ?? [] videoList: this.queue?.videoList ?? [],
}); });
break; break;
} }
@ -251,7 +287,8 @@ export default createPlugin<
if (permissionLevel >= 2) { if (permissionLevel >= 2) {
if (typeof event.payload?.progress === 'number') { if (typeof event.payload?.progress === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0; const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(event.payload.progress - currentTime) > 3) this.playerApi?.seekTo(event.payload.progress); if (Math.abs(event.payload.progress - currentTime) > 3)
this.playerApi?.seekTo(event.payload.progress);
} }
if (this.playerApi?.getPlayerState() !== event.payload?.state) { if (this.playerApi?.getPlayerState() !== event.payload?.state) {
if (event.payload?.state === 2) this.playerApi?.pauseVideo(); if (event.payload?.state === 2) this.playerApi?.pauseVideo();
@ -300,25 +337,32 @@ export default createPlugin<
this.profiles = {}; this.profiles = {};
const id = await this.showPrompt(t('plugins.music-together.name'), t('plugins.music-together.dialog.enter-host')); const id = await this.showPrompt(
t('plugins.music-together.name'),
t('plugins.music-together.dialog.enter-host'),
);
if (typeof id !== 'string') return false; if (typeof id !== 'string') return false;
const connection = await this.connection.connect(id).catch(() => false); const connection = await this.connection.connect(id).catch(() => false);
if (!connection) return false; if (!connection) return false;
this.connection.onConnections((connection) => { this.connection.onConnections((connection) => {
if (!connection?.open) { if (!connection?.open) {
this.api?.toastService?.show(t('plugins.music-together.toast.disconnected')); this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
this.onStop(); this.onStop();
} }
}); });
let resolveIgnore: number | null = null; let resolveIgnore: number | null = null;
const listener = async (event: ConnectionEventUnion) => { const listener = async (event: ConnectionEventUnion) => {
this.ignoreChange = true; this.ignoreChange = true;
switch (event.type) { switch (event.type) {
case 'ADD_SONGS': { case 'ADD_SONGS': {
await this.queue?.addVideos(event.payload.videoList, event.payload.index); await this.queue?.addVideos(
event.payload.videoList,
event.payload.index,
);
break; break;
} }
case 'REMOVE_SONG': { case 'REMOVE_SONG': {
@ -326,11 +370,16 @@ export default createPlugin<
break; break;
} }
case 'MOVE_SONG': { case 'MOVE_SONG': {
this.queue?.moveItem(event.payload.fromIndex, event.payload.toIndex); this.queue?.moveItem(
event.payload.fromIndex,
event.payload.toIndex,
);
break; break;
} }
case 'IDENTIFY': { case 'IDENTIFY': {
console.warn('Music Together [Guest]: Received "IDENTIFY" event from guest'); console.warn(
'Music Together [Guest]: Received "IDENTIFY" event from guest',
);
break; break;
} }
case 'SYNC_QUEUE': { case 'SYNC_QUEUE': {
@ -341,7 +390,9 @@ export default createPlugin<
} }
case 'SYNC_PROFILE': { case 'SYNC_PROFILE': {
if (!event.payload) { if (!event.payload) {
console.warn('Music Together [Guest]: Received "SYNC_PROFILE" event without payload'); console.warn(
'Music Together [Guest]: Received "SYNC_PROFILE" event without payload',
);
break; break;
} }
@ -353,7 +404,8 @@ export default createPlugin<
case 'SYNC_PROGRESS': { case 'SYNC_PROGRESS': {
if (typeof event.payload?.progress === 'number') { if (typeof event.payload?.progress === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0; const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(event.payload.progress - currentTime) > 3) this.playerApi?.seekTo(event.payload.progress); if (Math.abs(event.payload.progress - currentTime) > 3)
this.playerApi?.seekTo(event.payload.progress);
} }
if (this.playerApi?.getPlayerState() !== event.payload?.state) { if (this.playerApi?.getPlayerState() !== event.payload?.state) {
if (event.payload?.state === 2) this.playerApi?.pauseVideo(); if (event.payload?.state === 2) this.playerApi?.pauseVideo();
@ -370,7 +422,9 @@ export default createPlugin<
} }
case 'PERMISSION': { case 'PERMISSION': {
if (!event.payload) { if (!event.payload) {
console.warn('Music Together [Guest]: Received "PERMISSION" event without payload'); console.warn(
'Music Together [Guest]: Received "PERMISSION" event without payload',
);
break; break;
} }
@ -379,9 +433,15 @@ export default createPlugin<
this.popups.host.setPermission(this.permission); this.popups.host.setPermission(this.permission);
this.popups.setting.setPermission(this.permission); this.popups.setting.setPermission(this.permission);
const permissionLabel = t(`plugins.music-together.menu.permission.${this.permission}`); const permissionLabel = t(
`plugins.music-together.menu.permission.${this.permission}`,
);
this.api?.toastService?.show(t('plugins.music-together.toast.permission-changed', { permission: permissionLabel })); this.api?.toastService?.show(
t('plugins.music-together.toast.permission-changed', {
permission: permissionLabel,
}),
);
break; break;
} }
default: { default: {
@ -415,8 +475,10 @@ export default createPlugin<
break; break;
} }
case 'SYNC_PROGRESS': { case 'SYNC_PROGRESS': {
if (this.permission === 'host-only') await this.connection?.broadcast('SYNC_QUEUE', undefined); if (this.permission === 'host-only')
else await this.connection?.broadcast('SYNC_PROGRESS', event.payload); await this.connection?.broadcast('SYNC_QUEUE', undefined);
else
await this.connection?.broadcast('SYNC_PROGRESS', event.payload);
break; break;
} }
} }
@ -431,12 +493,16 @@ export default createPlugin<
this.queue?.injection(); this.queue?.injection();
this.queue?.setOwner({ this.queue?.setOwner({
id: this.connection.id, id: this.connection.id,
...this.me ...this.me,
}); });
const progress = Array.from(document.querySelectorAll<HTMLElement & { const progress = Array.from(
_update: (...args: unknown[]) => void document.querySelectorAll<
}>('tp-yt-paper-progress')); HTMLElement & {
_update: (...args: unknown[]) => void;
}
>('tp-yt-paper-progress'),
);
const rollbackList = progress.map((progress) => { const rollbackList = progress.map((progress) => {
const original = progress._update; const original = progress._update;
progress._update = (...args) => { progress._update = (...args) => {
@ -444,9 +510,10 @@ export default createPlugin<
if (this.permission === 'all' && typeof now === 'number') { if (this.permission === 'all' && typeof now === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0; const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(now - currentTime) > 3) this.connection?.broadcast('SYNC_PROGRESS', { if (Math.abs(now - currentTime) > 3)
this.connection?.broadcast('SYNC_PROGRESS', {
progress: now, progress: now,
state: this.playerApi?.getPlayerState() state: this.playerApi?.getPlayerState(),
}); });
} }
@ -466,8 +533,8 @@ export default createPlugin<
id: this.connection.id, id: this.connection.id,
handleId: this.me.handleId, handleId: this.me.handleId,
name: this.me.name, name: this.me.name,
thumbnail: this.me.thumbnail thumbnail: this.me.thumbnail,
} },
}); });
this.connection.broadcast('SYNC_PROFILE', undefined); this.connection.broadcast('SYNC_PROFILE', undefined);
@ -525,14 +592,18 @@ export default createPlugin<
}, },
initMyProfile() { initMyProfile() {
const accountButton = document.querySelector<HTMLElement & { const accountButton = document.querySelector<
onButtonTap: () => void HTMLElement & {
}>('ytmusic-settings-button'); onButtonTap: () => void;
}
>('ytmusic-settings-button');
accountButton?.onButtonTap(); accountButton?.onButtonTap();
setTimeout(() => { setTimeout(() => {
accountButton?.onButtonTap(); accountButton?.onButtonTap();
const renderer = document.querySelector<HTMLElement & { data: unknown }>('ytd-active-account-header-renderer'); const renderer = document.querySelector<
HTMLElement & { data: unknown }
>('ytd-active-account-header-renderer');
if (!accountButton || !renderer) { if (!accountButton || !renderer) {
console.warn('Music Together: Cannot find account'); console.warn('Music Together: Cannot find account');
this.me = getDefaultProfile(this.connection?.id ?? ''); this.me = getDefaultProfile(this.connection?.id ?? '');
@ -543,7 +614,7 @@ export default createPlugin<
this.me = { this.me = {
handleId: accountData.channelHandle.runs[0].text, handleId: accountData.channelHandle.runs[0].text,
name: accountData.accountName.runs[0].text, name: accountData.accountName.runs[0].text,
thumbnail: accountData.accountPhoto.thumbnails[0].url thumbnail: accountData.accountPhoto.thumbnails[0].url,
}; };
if (this.me.thumbnail) { if (this.me.thumbnail) {
@ -557,14 +628,23 @@ export default createPlugin<
start({ ipc }) { start({ ipc }) {
this.ipc = ipc; this.ipc = ipc;
this.showPrompt = async (title: string, label: string) => ipc.invoke('music-together:prompt', title, label) as Promise<string>; this.showPrompt = async (title: string, label: string) =>
ipc.invoke('music-together:prompt', title, label) as Promise<string>;
this.api = document.querySelector<AppElement>('ytmusic-app'); this.api = document.querySelector<AppElement>('ytmusic-app');
/* setup */ /* setup */
document.querySelector('#right-content > ytmusic-settings-button')?.insertAdjacentHTML('beforebegin', settingHTML); document
const setting = document.querySelector<HTMLElement>('#music-together-setting-button'); .querySelector('#right-content > ytmusic-settings-button')
const icon = document.querySelector<SVGElement>('#music-together-setting-button > svg'); ?.insertAdjacentHTML('beforebegin', settingHTML);
const spinner = document.querySelector<HTMLElement>('#music-together-setting-button > tp-yt-paper-spinner-lite'); const setting = document.querySelector<HTMLElement>(
'#music-together-setting-button',
);
const icon = document.querySelector<SVGElement>(
'#music-together-setting-button > svg',
);
const spinner = document.querySelector<HTMLElement>(
'#music-together-setting-button > tp-yt-paper-spinner-lite',
);
if (!setting || !icon || !spinner) { if (!setting || !icon || !spinner) {
console.warn('Music Together: Cannot inject html'); console.warn('Music Together: Cannot inject html');
console.log(setting, icon, spinner); console.log(setting, icon, spinner);
@ -574,7 +654,7 @@ export default createPlugin<
this.elements = { this.elements = {
setting, setting,
icon, icon,
spinner spinner,
}; };
this.stateInterval = window.setInterval(() => { this.stateInterval = window.setInterval(() => {
@ -584,7 +664,7 @@ export default createPlugin<
this.connection.broadcast('SYNC_PROGRESS', { this.connection.broadcast('SYNC_PROGRESS', {
progress: this.playerApi?.getCurrentTime(), progress: this.playerApi?.getCurrentTime(),
state: this.playerApi?.getPlayerState(), state: this.playerApi?.getPlayerState(),
index index,
}); });
}, 1000); }, 1000);
@ -593,18 +673,25 @@ export default createPlugin<
onItemClick: (id) => { onItemClick: (id) => {
if (id === 'music-together-close') { if (id === 'music-together-close') {
this.onStop(); this.onStop();
this.api?.toastService?.show(t('plugins.music-together.toast.closed')); this.api?.toastService?.show(
t('plugins.music-together.toast.closed'),
);
hostPopup.dismiss(); hostPopup.dismiss();
} }
if (id === 'music-together-copy-id') { if (id === 'music-together-copy-id') {
navigator.clipboard.writeText(this.connection?.id ?? '') navigator.clipboard
.writeText(this.connection?.id ?? '')
.then(() => { .then(() => {
this.api?.toastService?.show(t('plugins.music-together.toast.id-copied')); this.api?.toastService?.show(
t('plugins.music-together.toast.id-copied'),
);
hostPopup.dismiss(); hostPopup.dismiss();
}) })
.catch(() => { .catch(() => {
this.api?.toastService?.show(t('plugins.music-together.toast.id-copy-failed')); this.api?.toastService?.show(
t('plugins.music-together.toast.id-copy-failed'),
);
hostPopup.dismiss(); hostPopup.dismiss();
}); });
} }
@ -612,30 +699,39 @@ export default createPlugin<
if (id === 'music-together-permission') { if (id === 'music-together-permission') {
if (this.permission === 'all') this.permission = 'host-only'; if (this.permission === 'all') this.permission = 'host-only';
else if (this.permission === 'playlist') this.permission = 'all'; else if (this.permission === 'playlist') this.permission = 'all';
else if (this.permission === 'host-only') this.permission = 'playlist'; else if (this.permission === 'host-only')
this.permission = 'playlist';
this.connection?.broadcast('PERMISSION', this.permission); this.connection?.broadcast('PERMISSION', this.permission);
hostPopup.setPermission(this.permission); hostPopup.setPermission(this.permission);
guestPopup.setPermission(this.permission); guestPopup.setPermission(this.permission);
settingPopup.setPermission(this.permission); settingPopup.setPermission(this.permission);
const permissionLabel = t(`plugins.music-together.menu.permission.${this.permission}`); const permissionLabel = t(
this.api?.toastService?.show(t('plugins.music-together.toast.permission-changed', { permission: permissionLabel })); `plugins.music-together.menu.permission.${this.permission}`,
);
this.api?.toastService?.show(
t('plugins.music-together.toast.permission-changed', {
permission: permissionLabel,
}),
);
const item = hostPopup.items.find((it) => it?.element.id === id); const item = hostPopup.items.find((it) => it?.element.id === id);
if (item?.type === 'item') { if (item?.type === 'item') {
item.setText(t('plugins.music-together.menu.set-permission')); item.setText(t('plugins.music-together.menu.set-permission'));
} }
} }
} },
}); });
const guestPopup = createGuestPopup({ const guestPopup = createGuestPopup({
onItemClick: (id) => { onItemClick: (id) => {
if (id === 'music-together-disconnect') { if (id === 'music-together-disconnect') {
this.onStop(); this.onStop();
this.api?.toastService?.show(t('plugins.music-together.toast.disconnected')); this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
guestPopup.dismiss(); guestPopup.dismiss();
} }
} },
}); });
const settingPopup = createSettingPopup({ const settingPopup = createSettingPopup({
onItemClick: async (id) => { onItemClick: async (id) => {
@ -646,16 +742,24 @@ export default createPlugin<
this.hideSpinner(); this.hideSpinner();
if (result) { if (result) {
navigator.clipboard.writeText(this.connection?.id ?? '') navigator.clipboard
.writeText(this.connection?.id ?? '')
.then(() => { .then(() => {
this.api?.toastService?.show(t('plugins.music-together.toast.id-copied')); this.api?.toastService?.show(
t('plugins.music-together.toast.id-copied'),
);
hostPopup.showAtAnchor(setting); hostPopup.showAtAnchor(setting);
}).catch(() => { })
this.api?.toastService?.show(t('plugins.music-together.toast.id-copy-failed')); .catch(() => {
this.api?.toastService?.show(
t('plugins.music-together.toast.id-copy-failed'),
);
hostPopup.showAtAnchor(setting); hostPopup.showAtAnchor(setting);
}); });
} else { } else {
this.api?.toastService?.show(t('plugins.music-together.toast.host-failed')); this.api?.toastService?.show(
t('plugins.music-together.toast.host-failed'),
);
} }
} }
@ -666,18 +770,22 @@ export default createPlugin<
this.hideSpinner(); this.hideSpinner();
if (result) { if (result) {
this.api?.toastService?.show(t('plugins.music-together.toast.joined')); this.api?.toastService?.show(
t('plugins.music-together.toast.joined'),
);
guestPopup.showAtAnchor(setting); guestPopup.showAtAnchor(setting);
} else { } else {
this.api?.toastService?.show(t('plugins.music-together.toast.join-failed')); this.api?.toastService?.show(
} t('plugins.music-together.toast.join-failed'),
);
} }
} }
},
}); });
this.popups = { this.popups = {
host: hostPopup, host: hostPopup,
guest: guestPopup, guest: guestPopup,
setting: settingPopup setting: settingPopup,
}; };
setting.addEventListener('click', () => { setting.addEventListener('click', () => {
let popup = settingPopup; let popup = settingPopup;
@ -695,24 +803,38 @@ export default createPlugin<
this.queue = new Queue({ this.queue = new Queue({
owner: { owner: {
id: this.connection?.id ?? '', id: this.connection?.id ?? '',
...this.me! ...this.me!,
}, },
getProfile: (id) => this.profiles[id] getProfile: (id) => this.profiles[id],
}); });
this.playerApi = playerApi; this.playerApi = playerApi;
this.playerApi.addEventListener('onStateChange', this.videoStateChangeListener); this.playerApi.addEventListener(
'onStateChange',
this.videoStateChangeListener,
);
document.addEventListener('videodatachange', this.videoChangeListener); document.addEventListener('videodatachange', this.videoChangeListener);
}, },
stop() { stop() {
const dividers = Array.from(document.querySelectorAll('.music-together-divider')); const dividers = Array.from(
document.querySelectorAll('.music-together-divider'),
);
dividers.forEach((divider) => divider.remove()); dividers.forEach((divider) => divider.remove());
this.elements.setting?.remove(); this.elements.setting?.remove();
this.onStop(); this.onStop();
if (typeof this.stateInterval === 'number') clearInterval(this.stateInterval); if (typeof this.stateInterval === 'number')
if (this.playerApi) this.playerApi.removeEventListener('onStateChange', this.videoStateChangeListener); clearInterval(this.stateInterval);
if (this.videoChangeListener) document.removeEventListener('videodatachange', this.videoChangeListener); if (this.playerApi)
} this.playerApi.removeEventListener(
} 'onStateChange',
this.videoStateChangeListener,
);
if (this.videoChangeListener)
document.removeEventListener(
'videodatachange',
this.videoChangeListener,
);
},
},
}); });

View File

@ -1,11 +1,20 @@
import { SHA1Hash } from './sha1hash'; import { SHA1Hash } from './sha1hash';
export const extractToken = (cookie = document.cookie) => cookie.match(/SAPISID=([^;]+);/)?.[1] ?? cookie.match(/__Secure-3PAPISID=([^;]+);/)?.[1]; export const extractToken = (cookie = document.cookie) =>
cookie.match(/SAPISID=([^;]+);/)?.[1] ??
cookie.match(/__Secure-3PAPISID=([^;]+);/)?.[1];
export const getHash = async (papisid: string, millis = Date.now(), origin: string = 'https://music.youtube.com') => export const getHash = async (
(await SHA1Hash(`${millis} ${papisid} ${origin}`)).toLowerCase(); papisid: string,
millis = Date.now(),
origin: string = 'https://music.youtube.com',
) => (await SHA1Hash(`${millis} ${papisid} ${origin}`)).toLowerCase();
export const getAuthorizationHeader = async (papisid: string, millis = Date.now(), origin: string = 'https://music.youtube.com') => { export const getAuthorizationHeader = async (
papisid: string,
millis = Date.now(),
origin: string = 'https://music.youtube.com',
) => {
return `SAPISIDHASH ${millis}_${await getHash(papisid, millis, origin)}`; return `SAPISIDHASH ${millis}_${await getHash(papisid, millis, origin)}`;
}; };
@ -23,15 +32,17 @@ export const getClient = () => {
platform: 'DESKTOP', platform: 'DESKTOP',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locationInfo: { locationInfo: {
locationPermissionAuthorizationStatus: 'LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED', locationPermissionAuthorizationStatus:
'LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED',
}, },
musicAppInfo: { musicAppInfo: {
pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN', pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER', webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
storeDigitalGoodsApiSupportStatus: { storeDigitalGoodsApiSupportStatus: {
playStoreDigitalGoodsApiSupportStatus: 'DIGITAL_GOODS_API_SUPPORT_STATUS_UNSUPPORTED', playStoreDigitalGoodsApiSupportStatus:
'DIGITAL_GOODS_API_SUPPORT_STATUS_UNSUPPORTED',
}, },
}, },
utcOffsetMinutes: -1 * (new Date()).getTimezoneOffset(), utcOffsetMinutes: -1 * new Date().getTimezoneOffset(),
}; };
}; };

View File

@ -54,46 +54,46 @@ const getHeaderPayload = (() => {
title: { title: {
runs: [ runs: [
{ {
text: t('plugins.music-together.internal.track-source') text: t('plugins.music-together.internal.track-source'),
} },
] ],
}, },
subtitle: { subtitle: {
runs: [ runs: [
{ {
text: t('plugins.music-together.name') text: t('plugins.music-together.name'),
} },
] ],
}, },
buttons: [ buttons: [
{ {
chipCloudChipRenderer: { chipCloudChipRenderer: {
style: { style: {
styleType: 'STYLE_TRANSPARENT' styleType: 'STYLE_TRANSPARENT',
}, },
text: { text: {
runs: [ runs: [
{ {
text: t('plugins.music-together.internal.save') text: t('plugins.music-together.internal.save'),
} },
] ],
}, },
navigationEndpoint: { navigationEndpoint: {
saveQueueToPlaylistCommand: {} saveQueueToPlaylistCommand: {},
}, },
icon: { icon: {
iconType: 'ADD_TO_PLAYLIST' iconType: 'ADD_TO_PLAYLIST',
}, },
accessibilityData: { accessibilityData: {
accessibilityData: { accessibilityData: {
label: t('plugins.music-together.internal.save') label: t('plugins.music-together.internal.save'),
} },
}, },
isSelected: false, isSelected: false,
uniqueId: t('plugins.music-together.internal.save') uniqueId: t('plugins.music-together.internal.save'),
} },
} },
] ],
}; };
} }
@ -106,7 +106,7 @@ export type QueueOptions = {
owner?: Profile; owner?: Profile;
queue?: QueueElement; queue?: QueueElement;
getProfile: (id: string) => Profile | undefined; getProfile: (id: string) => Profile | undefined;
} };
export type QueueEventListener = (event: ConnectionEventUnion) => void; export type QueueEventListener = (event: ConnectionEventUnion) => void;
export class Queue { export class Queue {
@ -114,7 +114,7 @@ export class Queue {
private originalDispatch?: (obj: { private originalDispatch?: (obj: {
type: string; type: string;
payload?: { items?: QueueItem[] | undefined; }; payload?: { items?: QueueItem[] | undefined };
}) => void; }) => void;
private internalDispatch = false; private internalDispatch = false;
@ -126,7 +126,8 @@ export class Queue {
constructor(options: QueueOptions) { constructor(options: QueueOptions) {
this.getProfile = options.getProfile; this.getProfile = options.getProfile;
this.queue = options.queue ?? (document.querySelector<QueueElement>('#queue')!); this.queue =
options.queue ?? document.querySelector<QueueElement>('#queue')!;
this.owner = options.owner ?? null; this.owner = options.owner ?? null;
this._videoList = options.videoList ?? []; this._videoList = options.videoList ?? [];
} }
@ -139,7 +140,12 @@ export class Queue {
} }
get selectedIndex() { get selectedIndex() {
return mapQueueItem((it) => it?.selected, this.queue.queue.store.store.getState().queue.items).findIndex(Boolean) ?? 0; return (
mapQueueItem(
(it) => it?.selected,
this.queue.queue.store.store.getState().queue.items,
).findIndex(Boolean) ?? 0
);
} }
get rawItems() { get rawItems() {
@ -162,7 +168,9 @@ export class Queue {
} }
async addVideos(videos: VideoData[], index?: number) { async addVideos(videos: VideoData[], index?: number) {
const response = await getMusicQueueRenderer(videos.map((it) => it.videoId)); const response = await getMusicQueueRenderer(
videos.map((it) => it.videoId),
);
if (!response) return false; if (!response) return false;
const items = response.queueDatas.map((it) => it?.content).filter(Boolean); const items = response.queueDatas.map((it) => it?.content).filter(Boolean);
@ -173,12 +181,16 @@ export class Queue {
this.queue?.dispatch({ this.queue?.dispatch({
type: 'ADD_ITEMS', type: 'ADD_ITEMS',
payload: { payload: {
nextQueueItemId: this.queue.queue.store.store.getState().queue.nextQueueItemId, nextQueueItemId:
index: index ?? this.queue.queue.store.store.getState().queue.items.length ?? 0, this.queue.queue.store.store.getState().queue.nextQueueItemId,
index:
index ??
this.queue.queue.store.store.getState().queue.items.length ??
0,
items, items,
shuffleEnabled: false, shuffleEnabled: false,
shouldAssignIds: true shouldAssignIds: true,
} },
}); });
this.internalDispatch = false; this.internalDispatch = false;
setTimeout(() => { setTimeout(() => {
@ -194,7 +206,7 @@ export class Queue {
this._videoList.splice(index, 1); this._videoList.splice(index, 1);
this.queue?.dispatch({ this.queue?.dispatch({
type: 'REMOVE_ITEM', type: 'REMOVE_ITEM',
payload: index payload: index,
}); });
this.internalDispatch = false; this.internalDispatch = false;
setTimeout(() => { setTimeout(() => {
@ -207,7 +219,7 @@ export class Queue {
this.internalDispatch = true; this.internalDispatch = true;
this.queue?.dispatch({ this.queue?.dispatch({
type: 'SET_INDEX', type: 'SET_INDEX',
payload: index payload: index,
}); });
this.internalDispatch = false; this.internalDispatch = false;
} }
@ -220,8 +232,8 @@ export class Queue {
type: 'MOVE_ITEM', type: 'MOVE_ITEM',
payload: { payload: {
fromIndex, fromIndex,
toIndex toIndex,
} },
}); });
this.internalDispatch = false; this.internalDispatch = false;
setTimeout(() => { setTimeout(() => {
@ -234,7 +246,7 @@ export class Queue {
this.internalDispatch = true; this.internalDispatch = true;
this._videoList = []; this._videoList = [];
this.queue?.dispatch({ this.queue?.dispatch({
type: 'CLEAR' type: 'CLEAR',
}); });
this.internalDispatch = false; this.internalDispatch = false;
} }
@ -253,7 +265,8 @@ export class Queue {
return; return;
} }
if (this.originalDispatch) this.queue.queue.store.store.dispatch = this.originalDispatch; if (this.originalDispatch)
this.queue.queue.store.store.dispatch = this.originalDispatch;
} }
injection() { injection() {
@ -276,40 +289,54 @@ export class Queue {
if (event.type === 'ADD_ITEMS') { if (event.type === 'ADD_ITEMS') {
if (this.ignoreFlag) { if (this.ignoreFlag) {
this.ignoreFlag = false; this.ignoreFlag = false;
const videoList = mapQueueItem((it) => ({ const videoList = mapQueueItem(
(it) =>
({
videoId: it!.videoId, videoId: it!.videoId,
ownerId: this.owner!.id ownerId: this.owner!.id,
} satisfies VideoData), event.payload!.items!); }) satisfies VideoData,
event.payload!.items!,
);
const index = this._videoList.length + videoList.length - 1; const index = this._videoList.length + videoList.length - 1;
if (videoList.length > 0) { if (videoList.length > 0) {
this.broadcast({ // play this.broadcast({
// play
type: 'ADD_SONGS', type: 'ADD_SONGS',
payload: { payload: {
videoList videoList,
}, },
after: [ after: [
{ {
type: 'SYNC_PROGRESS', type: 'SYNC_PROGRESS',
payload: { payload: {
index index,
} },
} },
] ],
}); });
} }
} else if ((event.payload as { } else if (
(
event.payload as {
items: unknown[]; items: unknown[];
}).items.length === 1) { }
this.broadcast({ // add playlist ).items.length === 1
) {
this.broadcast({
// add playlist
type: 'ADD_SONGS', type: 'ADD_SONGS',
payload: { payload: {
// index: (event.payload as any).index, // index: (event.payload as any).index,
videoList: mapQueueItem((it) => ({ videoList: mapQueueItem(
(it) =>
({
videoId: it!.videoId, videoId: it!.videoId,
ownerId: this.owner!.id ownerId: this.owner!.id,
} satisfies VideoData), event.payload!.items!) }) satisfies VideoData,
} event.payload!.items!,
),
},
}); });
} }
@ -320,13 +347,17 @@ export class Queue {
this.broadcast({ this.broadcast({
type: 'MOVE_SONG', type: 'MOVE_SONG',
payload: { payload: {
fromIndex: (event.payload as { fromIndex: (
event.payload as {
fromIndex: number; fromIndex: number;
}).fromIndex,
toIndex: (event.payload as {
toIndex: number;
}).toIndex
} }
).fromIndex,
toIndex: (
event.payload as {
toIndex: number;
}
).toIndex,
},
}); });
return; return;
} }
@ -334,8 +365,8 @@ export class Queue {
this.broadcast({ this.broadcast({
type: 'REMOVE_SONG', type: 'REMOVE_SONG',
payload: { payload: {
index: event.payload as number index: event.payload as number,
} },
}); });
return; return;
} }
@ -343,8 +374,8 @@ export class Queue {
this.broadcast({ this.broadcast({
type: 'SYNC_PROGRESS', type: 'SYNC_PROGRESS',
payload: { payload: {
index: event.payload as number index: event.payload as number,
} },
}); });
return; return;
} }
@ -355,7 +386,10 @@ export class Queue {
event.payload = undefined; event.payload = undefined;
} }
if (event.type === 'SET_PLAYER_UI_STATE') { if (event.type === 'SET_PLAYER_UI_STATE') {
if (event.payload as string === 'INACTIVE' && this.videoList.length > 0) { if (
(event.payload as string) === 'INACTIVE' &&
this.videoList.length > 0
) {
return; return;
} }
} }
@ -370,7 +404,7 @@ export class Queue {
store: { store: {
...this.queue.queue.store, ...this.queue.queue.store,
dispatch: this.originalDispatch, dispatch: this.originalDispatch,
} },
}, },
}; };
this.originalDispatch?.call(fakeContext, event); this.originalDispatch?.call(fakeContext, event);
@ -384,20 +418,22 @@ export class Queue {
this.internalDispatch = true; this.internalDispatch = true;
this.queue.dispatch({ this.queue.dispatch({
type: 'HAS_SHOWN_AUTOPLAY', type: 'HAS_SHOWN_AUTOPLAY',
payload: false payload: false,
}); });
this.queue.dispatch({ this.queue.dispatch({
type: 'SET_HEADER', type: 'SET_HEADER',
payload: getHeaderPayload(), payload: getHeaderPayload(),
}); });
this.queue.dispatch({ this.queue.dispatch({
type: 'CLEAR_STEERING_CHIPS' type: 'CLEAR_STEERING_CHIPS',
}); });
this.internalDispatch = false; this.internalDispatch = false;
} }
async syncVideo() { async syncVideo() {
const response = await getMusicQueueRenderer(this._videoList.map((it) => it.videoId)); const response = await getMusicQueueRenderer(
this._videoList.map((it) => it.videoId),
);
if (!response) return false; if (!response) return false;
const items = response.queueDatas.map((it) => it.content); const items = response.queueDatas.map((it) => it.content);
@ -407,10 +443,11 @@ export class Queue {
type: 'UPDATE_ITEMS', type: 'UPDATE_ITEMS',
payload: { payload: {
items: items, items: items,
nextQueueItemId: this.queue.queue.store.store.getState().queue.nextQueueItemId, nextQueueItemId:
this.queue.queue.store.store.getState().queue.nextQueueItemId,
shouldAssignIds: true, shouldAssignIds: true,
currentIndex: -1 currentIndex: -1,
} },
}); });
this.internalDispatch = false; this.internalDispatch = false;
setTimeout(() => { setTimeout(() => {
@ -425,7 +462,9 @@ export class Queue {
const allQueue = document.querySelectorAll('#queue'); const allQueue = document.querySelectorAll('#queue');
allQueue.forEach((queue) => { allQueue.forEach((queue) => {
const list = Array.from(queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? []); const list = Array.from(
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
);
list.forEach((item, index: number | undefined) => { list.forEach((item, index: number | undefined) => {
if (typeof index !== 'number') return; if (typeof index !== 'number') return;
@ -433,14 +472,19 @@ export class Queue {
const id = this._videoList[index]?.ownerId; const id = this._videoList[index]?.ownerId;
const data = this.getProfile(id); const data = this.getProfile(id);
const profile = item.querySelector<HTMLImageElement>('.music-together-owner') ?? document.createElement('img'); const profile =
item.querySelector<HTMLImageElement>('.music-together-owner') ??
document.createElement('img');
profile.classList.add('music-together-owner'); profile.classList.add('music-together-owner');
profile.dataset.id = id; profile.dataset.id = id;
profile.dataset.index = index.toString(); profile.dataset.index = index.toString();
const name = item.querySelector<HTMLElement>('.music-together-name') ?? document.createElement('div'); const name =
item.querySelector<HTMLElement>('.music-together-name') ??
document.createElement('div');
name.classList.add('music-together-name'); name.classList.add('music-together-name');
name.textContent = data?.name ?? t('plugins.music-together.internal.unknown-user'); name.textContent =
data?.name ?? t('plugins.music-together.internal.unknown-user');
if (data) { if (data) {
profile.dataset.thumbnail = data.thumbnail ?? ''; profile.dataset.thumbnail = data.thumbnail ?? '';
@ -463,10 +507,14 @@ export class Queue {
const allQueue = document.querySelectorAll('#queue'); const allQueue = document.querySelectorAll('#queue');
allQueue.forEach((queue) => { allQueue.forEach((queue) => {
const list = Array.from(queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? []); const list = Array.from(
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
);
list.forEach((item) => { list.forEach((item) => {
const profile = item.querySelector<HTMLImageElement>('.music-together-owner'); const profile = item.querySelector<HTMLImageElement>(
'.music-together-owner',
);
const name = item.querySelector<HTMLElement>('.music-together-name'); const name = item.querySelector<HTMLElement>('.music-together-name');
profile?.remove(); profile?.remove();
name?.remove(); name?.remove();

View File

@ -8,7 +8,9 @@ type QueueRendererResponse = {
trackingParams: string; trackingParams: string;
}; };
export const getMusicQueueRenderer = async (videoIds: string[]): Promise<QueueRendererResponse | null> => { export const getMusicQueueRenderer = async (
videoIds: string[],
): Promise<QueueRendererResponse | null> => {
const token = extractToken(); const token = extractToken();
if (!token) return null; if (!token) return null;
@ -35,8 +37,8 @@ export const getMusicQueueRenderer = async (videoIds: string[]): Promise<QueueRe
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Origin': 'https://music.youtube.com', 'Origin': 'https://music.youtube.com',
'Authorization': await getAuthorizationHeader(token), 'Authorization': await getAuthorizationHeader(token),
} },
} },
); );
const text = await response.text(); const text = await response.text();

View File

@ -1,13 +1,19 @@
import { import {
ItemPlaylistPanelVideoRenderer, ItemPlaylistPanelVideoRenderer,
PlaylistPanelVideoWrapperRenderer, PlaylistPanelVideoWrapperRenderer,
QueueItem QueueItem,
} from '@/types/datahost-get-state'; } from '@/types/datahost-get-state';
export const mapQueueItem = <T>(map: (item?: ItemPlaylistPanelVideoRenderer) => T, array: QueueItem[]): T[] => array export const mapQueueItem = <T>(
map: (item?: ItemPlaylistPanelVideoRenderer) => T,
array: QueueItem[],
): T[] =>
array
.map((item) => { .map((item) => {
if ('playlistPanelVideoWrapperRenderer' in item) { if ('playlistPanelVideoWrapperRenderer' in item) {
const keys = Object.keys(item.playlistPanelVideoWrapperRenderer!.primaryRenderer) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[]; const keys = Object.keys(
item.playlistPanelVideoWrapperRenderer!.primaryRenderer,
) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[];
return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]]; return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]];
} }
if ('playlistPanelVideoRenderer' in item) { if ('playlistPanelVideoRenderer' in item) {
@ -18,4 +24,3 @@ export const mapQueueItem = <T>(map: (item?: ItemPlaylistPanelVideoRenderer) =>
return undefined; return undefined;
}) })
.map(map); .map(map);

View File

@ -10,13 +10,16 @@ export type VideoData = {
}; };
export type Permission = 'host-only' | 'playlist' | 'all'; export type Permission = 'host-only' | 'playlist' | 'all';
export const getDefaultProfile = (connectionID: string, id: string = Date.now().toString()): Profile => { export const getDefaultProfile = (
connectionID: string,
id: string = Date.now().toString(),
): Profile => {
const name = `Guest ${id.slice(0, 4)}`; const name = `Guest ${id.slice(0, 4)}`;
return { return {
id: connectionID, id: connectionID,
handleId: `#music-together:${id}`, handleId: `#music-together:${id}`,
name, name,
thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random` thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`,
}; };
}; };

View File

@ -7,7 +7,6 @@ import { createStatus } from '../ui/status';
import IconOff from '../icons/off.svg?raw'; import IconOff from '../icons/off.svg?raw';
export type GuestPopupProps = { export type GuestPopupProps = {
onItemClick: (id: string) => void; onItemClick: (id: string) => void;
}; };
@ -33,7 +32,7 @@ export const createGuestPopup = (props: GuestPopupProps) => {
}, },
], ],
anchorAt: 'bottom-right', anchorAt: 'bottom-right',
popupAt: 'top-right' popupAt: 'top-right',
}); });
return { return {

View File

@ -22,7 +22,7 @@ export const createHostPopup = (props: HostPopupProps) => {
element: status.element, element: status.element,
}, },
{ {
type: 'divider' type: 'divider',
}, },
{ {
id: 'music-together-copy-id', id: 'music-together-copy-id',
@ -35,7 +35,9 @@ export const createHostPopup = (props: HostPopupProps) => {
id: 'music-together-permission', id: 'music-together-permission',
type: 'item', type: 'item',
icon: ElementFromHtml(IconTune), icon: ElementFromHtml(IconTune),
text: t('plugins.music-together.menu.set-permission', { permission: t('plugins.music-together.menu.permission.host-only') }), text: t('plugins.music-together.menu.set-permission', {
permission: t('plugins.music-together.menu.permission.host-only'),
}),
onClick: () => props.onItemClick('music-together-permission'), onClick: () => props.onItemClick('music-together-permission'),
}, },
{ {

View File

@ -39,7 +39,7 @@ export const createSettingPopup = (props: SettingPopupProps) => {
}, },
], ],
anchorAt: 'bottom-right', anchorAt: 'bottom-right',
popupAt: 'top-right' popupAt: 'top-right',
}); });
return { return {

View File

@ -7,17 +7,27 @@ import type { Permission, Profile } from '../types';
export const createStatus = () => { export const createStatus = () => {
const element = ElementFromHtml(statusHTML); const element = ElementFromHtml(statusHTML);
const icon = document.querySelector<HTMLImageElement>('ytmusic-settings-button > tp-yt-paper-icon-button > tp-yt-iron-icon#icon img'); const icon = document.querySelector<HTMLImageElement>(
'ytmusic-settings-button > tp-yt-paper-icon-button > tp-yt-iron-icon#icon img',
);
const profile = element.querySelector<HTMLImageElement>('.music-together-profile')!; const profile = element.querySelector<HTMLImageElement>(
const statusLabel = element.querySelector<HTMLSpanElement>('#music-together-status-label')!; '.music-together-profile',
const permissionLabel = element.querySelector<HTMLMarqueeElement>('#music-together-permission-label')!; )!;
const statusLabel = element.querySelector<HTMLSpanElement>(
'#music-together-status-label',
)!;
const permissionLabel = element.querySelector<HTMLMarqueeElement>(
'#music-together-permission-label',
)!;
profile.src = icon?.src ?? ''; profile.src = icon?.src ?? '';
const setStatus = (status: 'disconnected' | 'host' | 'guest') => { const setStatus = (status: 'disconnected' | 'host' | 'guest') => {
if (status === 'disconnected') { if (status === 'disconnected') {
statusLabel.textContent = t('plugins.music-together.menu.status.disconnected'); statusLabel.textContent = t(
'plugins.music-together.menu.status.disconnected',
);
statusLabel.style.color = 'rgba(255, 255, 255, 0.5)'; statusLabel.style.color = 'rgba(255, 255, 255, 0.5)';
} }
@ -34,17 +44,23 @@ export const createStatus = () => {
const setPermission = (permission: Permission) => { const setPermission = (permission: Permission) => {
if (permission === 'host-only') { if (permission === 'host-only') {
permissionLabel.textContent = t('plugins.music-together.menu.permission.host-only'); permissionLabel.textContent = t(
'plugins.music-together.menu.permission.host-only',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 0.5)'; permissionLabel.style.color = 'rgba(255, 255, 255, 0.5)';
} }
if (permission === 'playlist') { if (permission === 'playlist') {
permissionLabel.textContent = t('plugins.music-together.menu.permission.playlist'); permissionLabel.textContent = t(
'plugins.music-together.menu.permission.playlist',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 0.75)'; permissionLabel.style.color = 'rgba(255, 255, 255, 0.75)';
} }
if (permission === 'all') { if (permission === 'all') {
permissionLabel.textContent = t('plugins.music-together.menu.permission.all'); permissionLabel.textContent = t(
'plugins.music-together.menu.permission.all',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 1)'; permissionLabel.style.color = 'rgba(255, 255, 255, 1)';
} }
}; };
@ -54,7 +70,9 @@ export const createStatus = () => {
}; };
const setUsers = (users: Profile[]) => { const setUsers = (users: Profile[]) => {
const container = element.querySelector<HTMLDivElement>('.music-together-user-container')!; const container = element.querySelector<HTMLDivElement>(
'.music-together-user-container',
)!;
const empty = element.querySelector<HTMLElement>('.music-together-empty')!; const empty = element.querySelector<HTMLElement>('.music-together-empty')!;
for (const child of Array.from(container.children)) { for (const child of Array.from(container.children)) {
if (child !== empty) child.remove(); if (child !== empty) child.remove();

View File

@ -68,7 +68,9 @@ const observer = new MutationObserver(() => {
if (!menuUrl?.includes('watch?')) { if (!menuUrl?.includes('watch?')) {
menuUrl = undefined; menuUrl = undefined;
// check for podcast // check for podcast
for (const it of document.querySelectorAll('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')) { for (const it of document.querySelectorAll(
'tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint',
)) {
if (it.getAttribute('href')?.includes('podcast/')) { if (it.getAttribute('href')?.includes('podcast/')) {
menuUrl = it.getAttribute('href')!; menuUrl = it.getAttribute('href')!;
break; break;

View File

@ -53,10 +53,7 @@ const observePopupContainer = () => {
menu = getSongMenu(); menu = getSongMenu();
} }
if ( if (menu && !menu.contains(slider)) {
menu &&
!menu.contains(slider)
) {
menu.prepend(slider); menu.prepend(slider);
setupSliderListener(); setupSliderListener();
} }

View File

@ -5,13 +5,13 @@ import { onMenu } from './menu';
import { backend } from './main'; import { backend } from './main';
export interface ScrobblerPluginConfig { export interface ScrobblerPluginConfig {
enabled: boolean, enabled: boolean;
/** /**
* Attempt to scrobble other video types (e.g. Podcasts, normal YouTube videos) * Attempt to scrobble other video types (e.g. Podcasts, normal YouTube videos)
* *
* @default true * @default true
*/ */
scrobbleOtherMedia: boolean, scrobbleOtherMedia: boolean;
scrobblers: { scrobblers: {
lastfm: { lastfm: {
/** /**
@ -19,53 +19,53 @@ export interface ScrobblerPluginConfig {
* *
* @default false * @default false
*/ */
enabled: boolean, enabled: boolean;
/** /**
* Token used for authentication * Token used for authentication
*/ */
token: string | undefined, token: string | undefined;
/** /**
* Session key used for scrobbling * Session key used for scrobbling
*/ */
sessionKey: string | undefined, sessionKey: string | undefined;
/** /**
* Root of the Last.fm API * Root of the Last.fm API
* *
* @default 'http://ws.audioscrobbler.com/2.0/' * @default 'http://ws.audioscrobbler.com/2.0/'
*/ */
apiRoot: string, apiRoot: string;
/** /**
* Last.fm api key registered by @semvis123 * Last.fm api key registered by @semvis123
* *
* @default '04d76faaac8726e60988e14c105d421a' * @default '04d76faaac8726e60988e14c105d421a'
*/ */
apiKey: string, apiKey: string;
/** /**
* Last.fm api secret registered by @semvis123 * Last.fm api secret registered by @semvis123
* *
* @default 'a5d2a36fdf64819290f6982481eaffa2' * @default 'a5d2a36fdf64819290f6982481eaffa2'
*/ */
secret: string, secret: string;
}, };
listenbrainz: { listenbrainz: {
/** /**
* Enable ListenBrainz scrobbling * Enable ListenBrainz scrobbling
* *
* @default false * @default false
*/ */
enabled: boolean, enabled: boolean;
/** /**
* Listenbrainz user token * Listenbrainz user token
*/ */
token: string | undefined, token: string | undefined;
/** /**
* Root of the ListenBrainz API * Root of the ListenBrainz API
* *
* @default 'https://api.listenbrainz.org/1/' * @default 'https://api.listenbrainz.org/1/'
*/ */
apiRoot: string, apiRoot: string;
}, };
} };
} }
export const defaultConfig: ScrobblerPluginConfig = { export const defaultConfig: ScrobblerPluginConfig = {

View File

@ -1,6 +1,9 @@
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
import registerCallback, { MediaType, type SongInfo } from '@/providers/song-info'; import registerCallback, {
MediaType,
type SongInfo,
} from '@/providers/song-info';
import { createBackend } from '@/utils'; import { createBackend } from '@/utils';
import { LastFmScrobbler } from './services/lastfm'; import { LastFmScrobbler } from './services/lastfm';
@ -13,14 +16,23 @@ export type SetConfType = (
conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>, conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>,
) => void | Promise<void>; ) => void | Promise<void>;
export const backend = createBackend<{ export const backend = createBackend<
{
config?: ScrobblerPluginConfig; config?: ScrobblerPluginConfig;
window?: BrowserWindow; window?: BrowserWindow;
enabledScrobblers: Map<string, ScrobblerBase>; enabledScrobblers: Map<string, ScrobblerBase>;
toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow): void; toggleScrobblers(
createSessions(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<void>; config: ScrobblerPluginConfig,
window: BrowserWindow,
): void;
createSessions(
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): Promise<void>;
setConfig?: SetConfType; setConfig?: SetConfType;
}, ScrobblerPluginConfig>({ },
ScrobblerPluginConfig
>({
enabledScrobblers: new Map(), enabledScrobblers: new Map(),
toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow) { toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow) {
@ -30,7 +42,10 @@ export const backend = createBackend<{
this.enabledScrobblers.delete('lastfm'); this.enabledScrobblers.delete('lastfm');
} }
if (config.scrobblers.listenbrainz && config.scrobblers.listenbrainz.enabled) { if (
config.scrobblers.listenbrainz &&
config.scrobblers.listenbrainz.enabled
) {
this.enabledScrobblers.set('listenbrainz', new ListenbrainzScrobbler()); this.enabledScrobblers.set('listenbrainz', new ListenbrainzScrobbler());
} else { } else {
this.enabledScrobblers.delete('listenbrainz'); this.enabledScrobblers.delete('listenbrainz');
@ -45,12 +60,8 @@ export const backend = createBackend<{
} }
}, },
async start({ async start({ getConfig, setConfig, window }) {
getConfig, const config = (this.config = await getConfig());
setConfig,
window,
}) {
const config = this.config = await getConfig();
// This will store the timeout that will trigger addScrobble // This will store the timeout that will trigger addScrobble
let scrobbleTimer: NodeJS.Timeout | undefined; let scrobbleTimer: NodeJS.Timeout | undefined;
@ -65,21 +76,38 @@ export const backend = createBackend<{
if (!songInfo.isPaused) { if (!songInfo.isPaused) {
const configNonnull = this.config!; const configNonnull = this.config!;
// Scrobblers normally have no trouble working with official music videos // Scrobblers normally have no trouble working with official music videos
if (!configNonnull.scrobbleOtherMedia && (songInfo.mediaType !== MediaType.Audio && songInfo.mediaType !== MediaType.OriginalMusicVideo)) { if (
!configNonnull.scrobbleOtherMedia &&
songInfo.mediaType !== MediaType.Audio &&
songInfo.mediaType !== MediaType.OriginalMusicVideo
) {
return; return;
} }
// Scrobble when the song is halfway through, or has passed the 4-minute mark // Scrobble when the song is halfway through, or has passed the 4-minute mark
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60); const scrobbleTime = Math.min(
Math.ceil(songInfo.songDuration / 2),
4 * 60,
);
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) { if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
// Scrobble still needs to happen // Scrobble still needs to happen
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000; const timeToWait =
scrobbleTimer = setTimeout((info, config) => { (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
this.enabledScrobblers.forEach((scrobbler) => scrobbler.addScrobble(info, config, setConfig)); scrobbleTimer = setTimeout(
}, timeToWait, songInfo, configNonnull); (info, config) => {
this.enabledScrobblers.forEach((scrobbler) =>
scrobbler.addScrobble(info, config, setConfig),
);
},
timeToWait,
songInfo,
configNonnull,
);
} }
this.enabledScrobblers.forEach((scrobbler) => scrobbler.setNowPlaying(songInfo, configNonnull, setConfig)); this.enabledScrobblers.forEach((scrobbler) =>
scrobbler.setNowPlaying(songInfo, configNonnull, setConfig),
);
} }
}); });
}, },
@ -88,11 +116,15 @@ export const backend = createBackend<{
this.enabledScrobblers.clear(); this.enabledScrobblers.clear();
this.toggleScrobblers(newConfig, this.window!); this.toggleScrobblers(newConfig, this.window!);
for (const [scrobblerName, scrobblerConfig] of Object.entries(newConfig.scrobblers)) { for (const [scrobblerName, scrobblerConfig] of Object.entries(
newConfig.scrobblers,
)) {
if (scrobblerConfig.enabled) { if (scrobblerConfig.enabled) {
const scrobbler = this.enabledScrobblers.get(scrobblerName); const scrobbler = this.enabledScrobblers.get(scrobblerName);
if ( if (
this.config?.scrobblers?.[scrobblerName as keyof typeof newConfig.scrobblers]?.enabled !== scrobblerConfig.enabled && this.config?.scrobblers?.[
scrobblerName as keyof typeof newConfig.scrobblers
]?.enabled !== scrobblerConfig.enabled &&
scrobbler && scrobbler &&
!scrobbler.isSessionCreated(newConfig) && !scrobbler.isSessionCreated(newConfig) &&
this.setConfig this.setConfig
@ -103,6 +135,5 @@ export const backend = createBackend<{
} }
this.config = newConfig; this.config = newConfig;
} },
}); });

View File

@ -11,7 +11,11 @@ import { SetConfType, backend } from './main';
import type { MenuContext } from '@/types/contexts'; import type { MenuContext } from '@/types/contexts';
import type { MenuTemplate } from '@/menu'; import type { MenuTemplate } from '@/menu';
async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: SetConfType, window: BrowserWindow) { async function promptLastFmOptions(
options: ScrobblerPluginConfig,
setConfig: SetConfType,
window: BrowserWindow,
) {
const output = await prompt( const output = await prompt(
{ {
title: t('plugins.scrobbler.menu.lastfm.api-settings'), title: t('plugins.scrobbler.menu.lastfm.api-settings'),
@ -22,16 +26,16 @@ async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: Se
label: t('plugins.scrobbler.prompt.lastfm.api-key'), label: t('plugins.scrobbler.prompt.lastfm.api-key'),
value: options.scrobblers.lastfm?.apiKey, value: options.scrobblers.lastfm?.apiKey,
inputAttrs: { inputAttrs: {
type: 'text' type: 'text',
} },
}, },
{ {
label: t('plugins.scrobbler.prompt.lastfm.api-secret'), label: t('plugins.scrobbler.prompt.lastfm.api-secret'),
value: options.scrobblers.lastfm?.secret, value: options.scrobblers.lastfm?.secret,
inputAttrs: { inputAttrs: {
type: 'text' type: 'text',
} },
} },
], ],
resizable: true, resizable: true,
height: 360, height: 360,
@ -53,7 +57,11 @@ async function promptLastFmOptions(options: ScrobblerPluginConfig, setConfig: Se
} }
} }
async function promptListenbrainzOptions(options: ScrobblerPluginConfig, setConfig: SetConfType, window: BrowserWindow) { async function promptListenbrainzOptions(
options: ScrobblerPluginConfig,
setConfig: SetConfType,
window: BrowserWindow,
) {
const output = await prompt( const output = await prompt(
{ {
title: t('plugins.scrobbler.prompt.listenbrainz.token.title'), title: t('plugins.scrobbler.prompt.listenbrainz.token.title'),

View File

@ -5,9 +5,20 @@ import type { SongInfo } from '@/providers/song-info';
export abstract class ScrobblerBase { export abstract class ScrobblerBase {
public abstract isSessionCreated(config: ScrobblerPluginConfig): boolean; public abstract isSessionCreated(config: ScrobblerPluginConfig): boolean;
public abstract createSession(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<ScrobblerPluginConfig>; public abstract createSession(
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): Promise<ScrobblerPluginConfig>;
public abstract setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void; public abstract setNowPlaying(
songInfo: SongInfo,
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): void;
public abstract addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void; public abstract addScrobble(
songInfo: SongInfo,
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): void;
} }

View File

@ -81,7 +81,11 @@ export class LastFmScrobbler extends ScrobblerBase {
return config; return config;
} }
override setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void { override setNowPlaying(
songInfo: SongInfo,
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): void {
if (!config.scrobblers.lastfm.sessionKey) { if (!config.scrobblers.lastfm.sessionKey) {
return; return;
} }
@ -93,7 +97,11 @@ export class LastFmScrobbler extends ScrobblerBase {
this.postSongDataToAPI(songInfo, config, data, setConfig); this.postSongDataToAPI(songInfo, config, data, setConfig);
} }
override addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, setConfig: SetConfType): void { override addScrobble(
songInfo: SongInfo,
config: ScrobblerPluginConfig,
setConfig: SetConfType,
): void {
if (!config.scrobblers.lastfm.sessionKey) { if (!config.scrobblers.lastfm.sessionKey) {
return; return;
} }
@ -101,7 +109,9 @@ export class LastFmScrobbler extends ScrobblerBase {
// This adds one scrobbled song to last.fm // This adds one scrobbled song to last.fm
const data = { const data = {
method: 'track.scrobble', method: 'track.scrobble',
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000), timestamp: Math.trunc(
(Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000,
),
}; };
this.postSongDataToAPI(songInfo, config, data, setConfig); this.postSongDataToAPI(songInfo, config, data, setConfig);
} }
@ -195,8 +205,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
// This function creates the api signature, see: https://www.last.fm/api/authspec // This function creates the api signature, see: https://www.last.fm/api/authspec
let sig = ''; let sig = '';
Object Object.entries(parameters)
.entries(parameters)
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.forEach(([key, value]) => { .forEach(([key, value]) => {
if (key === 'format') { if (key === 'format') {
@ -212,12 +221,8 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
const createToken = async ({ const createToken = async ({
scrobblers: { scrobblers: {
lastfm: { lastfm: { apiKey, apiRoot, secret },
apiKey, },
apiRoot,
secret,
}
}
}: ScrobblerPluginConfig) => { }: ScrobblerPluginConfig) => {
// Creates and stores the auth token // Creates and stores the auth token
const data: { const data: {
@ -240,7 +245,10 @@ const createToken = async ({
let authWindowOpened = false; let authWindowOpened = false;
let latestAuthResult = false; let latestAuthResult = false;
const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWindow) => { const authenticate = async (
config: ScrobblerPluginConfig,
mainWindow: BrowserWindow,
) => {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
if (!authWindowOpened) { if (!authWindowOpened) {
authWindowOpened = true; authWindowOpened = true;
@ -266,9 +274,10 @@ const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWi
const url = new URL(newUrl); const url = new URL(newUrl);
if (url.hostname.endsWith('last.fm')) { if (url.hostname.endsWith('last.fm')) {
if (url.pathname === '/api/auth') { if (url.pathname === '/api/auth') {
const isApproveScreen = await browserWindow.webContents.executeJavaScript( const isApproveScreen =
'!!document.getElementsByName(\'confirm\').length' (await browserWindow.webContents.executeJavaScript(
) as boolean; "!!document.getElementsByName('confirm').length",
)) as boolean;
// successful authentication // successful authentication
if (!isApproveScreen) { if (!isApproveScreen) {
resolve(true); resolve(true);
@ -287,7 +296,7 @@ const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWi
dialog.showMessageBox({ dialog.showMessageBox({
title: t('plugins.scrobbler.dialog.lastfm.auth-failed.title'), title: t('plugins.scrobbler.dialog.lastfm.auth-failed.title'),
message: t('plugins.scrobbler.dialog.lastfm.auth-failed.message'), message: t('plugins.scrobbler.dialog.lastfm.auth-failed.message'),
type: 'error' type: 'error',
}); });
} }
authWindowOpened = false; authWindowOpened = false;

View File

@ -29,12 +29,22 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
return true; return true;
} }
override createSession(config: ScrobblerPluginConfig, _setConfig: SetConfType): Promise<ScrobblerPluginConfig> { override createSession(
config: ScrobblerPluginConfig,
_setConfig: SetConfType,
): Promise<ScrobblerPluginConfig> {
return Promise.resolve(config); return Promise.resolve(config);
} }
override setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void { override setNowPlaying(
if (!config.scrobblers.listenbrainz.apiRoot || !config.scrobblers.listenbrainz.token) { songInfo: SongInfo,
config: ScrobblerPluginConfig,
_setConfig: SetConfType,
): void {
if (
!config.scrobblers.listenbrainz.apiRoot ||
!config.scrobblers.listenbrainz.token
) {
return; return;
} }
@ -42,8 +52,15 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
submitListen(body, config); submitListen(body, config);
} }
override addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void { override addScrobble(
if (!config.scrobblers.listenbrainz.apiRoot || !config.scrobblers.listenbrainz.token) { songInfo: SongInfo,
config: ScrobblerPluginConfig,
_setConfig: SetConfType,
): void {
if (
!config.scrobblers.listenbrainz.apiRoot ||
!config.scrobblers.listenbrainz.token
) {
return; return;
} }
@ -54,7 +71,10 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
} }
} }
function createRequestBody(listenType: string, songInfo: SongInfo): ListenbrainzRequestBody { function createRequestBody(
listenType: string,
songInfo: SongInfo,
): ListenbrainzRequestBody {
const trackMetadata = { const trackMetadata = {
artist_name: songInfo.artist, artist_name: songInfo.artist,
track_name: songInfo.title, track_name: songInfo.title,
@ -64,7 +84,7 @@ function createRequestBody(listenType: string, songInfo: SongInfo): Listenbrainz
submission_client: 'YouTube Music Desktop App - Scrobbler Plugin', submission_client: 'YouTube Music Desktop App - Scrobbler Plugin',
origin_url: songInfo.url, origin_url: songInfo.url,
duration: songInfo.songDuration, duration: songInfo.songDuration,
} },
}; };
return { return {
@ -72,19 +92,23 @@ function createRequestBody(listenType: string, songInfo: SongInfo): Listenbrainz
payload: [ payload: [
{ {
track_metadata: trackMetadata, track_metadata: trackMetadata,
} },
] ],
}; };
} }
function submitListen(body: ListenbrainzRequestBody, config: ScrobblerPluginConfig) { function submitListen(
net.fetch(config.scrobblers.listenbrainz.apiRoot + 'submit-listens', body: ListenbrainzRequestBody,
{ config: ScrobblerPluginConfig,
) {
net
.fetch(config.scrobblers.listenbrainz.apiRoot + 'submit-listens', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
headers: { headers: {
'Authorization': 'Token ' + config.scrobblers.listenbrainz.token, 'Authorization': 'Token ' + config.scrobblers.listenbrainz.token,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} },
}).catch(console.error); })
.catch(console.error);
} }

View File

@ -176,15 +176,17 @@ declare module '@jellybrick/mpris-service' {
setActivePlaylist(playlistId: string): void; setActivePlaylist(playlistId: string): void;
} }
interface MprisInterface extends dbusInterface.Interface { export interface MprisInterface extends dbusInterface.Interface {
setProperty(property: string, valuePlain: unknown): void; setProperty(property: string, valuePlain: unknown): void;
} }
interface RootInterface {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface RootInterface {}
interface PlayerInterface {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface PlayerInterface {}
interface TracklistInterface { export interface TracklistInterface {
TrackListReplaced(tracks: Track[]): void; TrackListReplaced(tracks: Track[]): void;
TrackAdded(afterTrack: string): void; TrackAdded(afterTrack: string): void;
@ -192,7 +194,7 @@ declare module '@jellybrick/mpris-service' {
TrackRemoved(trackId: string): void; TrackRemoved(trackId: string): void;
} }
interface PlaylistsInterface { export interface PlaylistsInterface {
PlaylistChanged(playlist: unknown[]): void; PlaylistChanged(playlist: unknown[]): void;
setActivePlaylistId(playlistId: string): void; setActivePlaylistId(playlistId: string): void;

View File

@ -192,9 +192,12 @@ function registerMPRIS(win: BrowserWindow) {
return; return;
} }
const currentPosition = queue.items?.findIndex((it) => const currentPosition =
queue.items?.findIndex(
(it) =>
it?.playlistPanelVideoRenderer?.selected || it?.playlistPanelVideoRenderer?.selected ||
it?.playlistPanelVideoWrapperRenderer?.primaryRenderer?.playlistPanelVideoRenderer?.selected it?.playlistPanelVideoWrapperRenderer?.primaryRenderer
?.playlistPanelVideoRenderer?.selected,
) ?? 0; ) ?? 0;
player.canGoPrevious = currentPosition !== 0; player.canGoPrevious = currentPosition !== 0;

View File

@ -16,11 +16,14 @@ export default createPlugin<
restartNeeded: false, restartNeeded: false,
renderer: { renderer: {
start() { start() {
waitForElement<HTMLElement>('#dislike-button-renderer').then((dislikeBtn) => { waitForElement<HTMLElement>('#dislike-button-renderer').then(
(dislikeBtn) => {
this.observer = new MutationObserver(() => { this.observer = new MutationObserver(() => {
if (dislikeBtn?.getAttribute('like-status') == 'DISLIKE') { if (dislikeBtn?.getAttribute('like-status') == 'DISLIKE') {
document document
.querySelector<HTMLButtonElement>('tp-yt-paper-icon-button.next-button') .querySelector<HTMLButtonElement>(
'tp-yt-paper-icon-button.next-button',
)
?.click(); ?.click();
} }
}); });
@ -29,7 +32,8 @@ export default createPlugin<
childList: false, childList: false,
subtree: false, subtree: false,
}); });
}); },
);
}, },
stop() { stop() {
this.observer?.disconnect(); this.observer?.disconnect();

View File

@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-require-imports */
// eslint-disable-next-line no-undef
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
// eslint-disable-next-line no-undef
const { sortSegments } = require('../segments'); const { sortSegments } = require('../segments');
test('Segment sorting', () => { test('Segment sorting', () => {

View File

@ -31,8 +31,12 @@ export const menu = async ({
type: 'submenu', type: 'submenu',
submenu: [ submenu: [
{ {
label: t('plugins.synced-lyrics.menu.line-effect.submenu.scale.label'), label: t(
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.scale.tooltip'), 'plugins.synced-lyrics.menu.line-effect.submenu.scale.label',
),
toolTip: t(
'plugins.synced-lyrics.menu.line-effect.submenu.scale.tooltip',
),
type: 'radio', type: 'radio',
checked: config.lineEffect === 'scale', checked: config.lineEffect === 'scale',
click() { click() {
@ -42,8 +46,12 @@ export const menu = async ({
}, },
}, },
{ {
label: t('plugins.synced-lyrics.menu.line-effect.submenu.offset.label'), label: t(
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.offset.tooltip'), 'plugins.synced-lyrics.menu.line-effect.submenu.offset.label',
),
toolTip: t(
'plugins.synced-lyrics.menu.line-effect.submenu.offset.tooltip',
),
type: 'radio', type: 'radio',
checked: config.lineEffect === 'offset', checked: config.lineEffect === 'offset',
click() { click() {
@ -53,8 +61,12 @@ export const menu = async ({
}, },
}, },
{ {
label: t('plugins.synced-lyrics.menu.line-effect.submenu.focus.label'), label: t(
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.focus.tooltip'), 'plugins.synced-lyrics.menu.line-effect.submenu.focus.label',
),
toolTip: t(
'plugins.synced-lyrics.menu.line-effect.submenu.focus.tooltip',
),
type: 'radio', type: 'radio',
checked: config.lineEffect === 'focus', checked: config.lineEffect === 'focus',
click() { click() {
@ -125,7 +137,9 @@ export const menu = async ({
}, },
{ {
label: t('plugins.synced-lyrics.menu.show-lyrics-even-if-inexact.label'), label: t('plugins.synced-lyrics.menu.show-lyrics-even-if-inexact.label'),
toolTip: t('plugins.synced-lyrics.menu.show-lyrics-even-if-inexact.tooltip'), toolTip: t(
'plugins.synced-lyrics.menu.show-lyrics-even-if-inexact.tooltip',
),
type: 'checkbox', type: 'checkbox',
checked: config.showLyricsEvenIfInexact, checked: config.showLyricsEvenIfInexact,
click(item) { click(item) {

View File

@ -28,7 +28,7 @@ export const LyricsContainer = () => {
const info = getSongInfo(); const info = getSongInfo();
await makeLyricsRequest(info).catch((err) => { await makeLyricsRequest(info).catch((err) => {
setError(`${err}`); setError(String(err));
}); });
}; };

View File

@ -14,12 +14,15 @@ import type { SyncedLyricsPluginConfig } from '../types';
export let _ytAPI: YoutubePlayer | null = null; export let _ytAPI: YoutubePlayer | null = null;
export const renderer = createRenderer<{ export const renderer = createRenderer<
{
observerCallback: MutationCallback; observerCallback: MutationCallback;
observer?: MutationObserver; observer?: MutationObserver;
videoDataChange: () => Promise<void>; videoDataChange: () => Promise<void>;
updateTimestampInterval?: NodeJS.Timeout | string | number; updateTimestampInterval?: NodeJS.Timeout | string | number;
}, SyncedLyricsPluginConfig>({ },
SyncedLyricsPluginConfig
>({
onConfigChange(newConfig) { onConfigChange(newConfig) {
setConfig(newConfig); setConfig(newConfig);
}, },
@ -57,9 +60,7 @@ export const renderer = createRenderer<{
); );
} }
this.observer ??= new MutationObserver( this.observer ??= new MutationObserver(this.observerCallback);
this.observerCallback,
);
// Force the lyrics tab to be enabled at all times. // Force the lyrics tab to be enabled at all times.
this.observer.disconnect(); this.observer.disconnect();

View File

@ -27,7 +27,7 @@ export const extractTimeAndText = (
parseInt(rMillis), parseInt(rMillis),
]; ];
const timeInMs = (minutes * 60 * 1000) + (seconds * 1000) + millis; const timeInMs = minutes * 60 * 1000 + seconds * 1000 + millis;
return { return {
index, index,
@ -75,7 +75,6 @@ export const getLyricsList = async (
track_name: songData.title, track_name: songData.title,
}); });
if (songData.album) { if (songData.album) {
query.set('album_name', songData.album); query.set('album_name', songData.album);
} }
@ -88,7 +87,7 @@ export const getLyricsList = async (
return null; return null;
} }
let data = await response.json() as LRCLIBSearchResponse; let data = (await response.json()) as LRCLIBSearchResponse;
if (!data || !Array.isArray(data)) { if (!data || !Array.isArray(data)) {
setDebugInfo('Unexpected server response.'); setDebugInfo('Unexpected server response.');
return null; return null;
@ -127,7 +126,10 @@ export const getLyricsList = async (
const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim()); const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim());
const permutations = artists.flatMap((artistA) => const permutations = artists.flatMap((artistA) =>
itemArtists.map((artistB) => [artistA.toLowerCase(), artistB.toLowerCase()]) itemArtists.map((artistB) => [
artistA.toLowerCase(),
artistB.toLowerCase(),
]),
); );
const ratio = Math.max(...permutations.map(([x, y]) => jaroWinkler(x, y))); const ratio = Math.max(...permutations.map(([x, y]) => jaroWinkler(x, y)));

View File

@ -7,8 +7,11 @@ import type { SyncedLyricsPluginConfig } from '../types';
export const [isVisible, setIsVisible] = createSignal<boolean>(false); export const [isVisible, setIsVisible] = createSignal<boolean>(false);
export const [config, setConfig] = createSignal<SyncedLyricsPluginConfig | null>(null); export const [config, setConfig] =
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(null); createSignal<SyncedLyricsPluginConfig | null>(null);
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(
null,
);
export const LyricsRenderer = () => { export const LyricsRenderer = () => {
return ( return (

View File

@ -92,7 +92,9 @@ export default createPlugin({
// Get image source // Get image source
songImage.icon = ( songImage.icon = (
songInfo.image ? songInfo.image : nativeImage.createFromPath(youtubeMusicIcon) songInfo.image
? songInfo.image
: nativeImage.createFromPath(youtubeMusicIcon)
).resize({ height: 23 }); ).resize({ height: 23 });
window.setTouchBar(touchBar); window.setTouchBar(touchBar);

View File

@ -1,4 +1,9 @@
import { contextBridge, ipcRenderer, IpcRendererEvent, webFrame } from 'electron'; import {
contextBridge,
ipcRenderer,
IpcRendererEvent,
webFrame,
} from 'electron';
import is from 'electron-is'; import is from 'electron-is';
import config from './config'; import config from './config';
@ -48,23 +53,33 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
sendToHost: (channel: string, ...args: unknown[]) => sendToHost: (channel: string, ...args: unknown[]) =>
ipcRenderer.sendToHost(channel, ...args), ipcRenderer.sendToHost(channel, ...args),
}); });
contextBridge.exposeInMainWorld('reload', () => ipcRenderer.send('ytmd:reload')); contextBridge.exposeInMainWorld('reload', () =>
ipcRenderer.send('ytmd:reload'),
);
contextBridge.exposeInMainWorld( contextBridge.exposeInMainWorld(
'ELECTRON_RENDERER_URL', 'ELECTRON_RENDERER_URL',
process.env.ELECTRON_RENDERER_URL, process.env.ELECTRON_RENDERER_URL,
); );
const [path, script] = ipcRenderer.sendSync('get-renderer-script') as [string | null, string]; const [path, script] = ipcRenderer.sendSync('get-renderer-script') as [
string | null,
string,
];
let blocked = true; let blocked = true;
if (path) { if (path) {
webFrame.executeJavaScriptInIsolatedWorld(0, [ webFrame.executeJavaScriptInIsolatedWorld(
0,
[
{ {
code: script, code: script,
url: path, url: path,
}, },
], true, () => blocked = false); ],
true,
() => (blocked = false),
);
} else { } else {
webFrame.executeJavaScript(script, true, () => blocked = false); webFrame.executeJavaScript(script, true, () => (blocked = false));
} }
// HACK: Wait for the script to be executed // HACK: Wait for the script to be executed

View File

@ -9,9 +9,7 @@ export const restart = () => restartInternal();
export const setupAppControls = () => { export const setupAppControls = () => {
ipcMain.on('ytmd:restart', restart); ipcMain.on('ytmd:restart', restart);
ipcMain.handle('ytmd:get-downloads-folder', () => app.getPath('downloads')); ipcMain.handle('ytmd:get-downloads-folder', () => app.getPath('downloads'));
ipcMain.on( ipcMain.on('ytmd:reload', () =>
'ytmd:reload',
() =>
BrowserWindow.getFocusedWindow()?.webContents.loadURL(config.get('url')), BrowserWindow.getFocusedWindow()?.webContents.loadURL(config.get('url')),
); );
ipcMain.handle('ytmd:get-path', (_, ...args: string[]) => path.join(...args)); ipcMain.handle('ytmd:get-path', (_, ...args: string[]) => path.join(...args));

View File

@ -80,7 +80,7 @@ function memoize<T extends (...params: unknown[]) => unknown>(fn: T): T {
cache.set(key, fn(...args)); cache.set(key, fn(...args));
} }
return cache.get(key) as unknown; return cache.get(key);
}) as T; }) as T;
} }

View File

@ -6,7 +6,9 @@ import getSongControls from './song-controls';
export const APP_PROTOCOL = 'youtubemusic'; export const APP_PROTOCOL = 'youtubemusic';
let protocolHandler: ((cmd: string, args: string[] | undefined) => void) | undefined; let protocolHandler:
| ((cmd: string, args: string[] | undefined) => void)
| undefined;
export function setupProtocolHandler(win: BrowserWindow) { export function setupProtocolHandler(win: BrowserWindow) {
if (process.defaultApp && process.argv.length >= 2) { if (process.defaultApp && process.argv.length >= 2) {
@ -19,7 +21,10 @@ export function setupProtocolHandler(win: BrowserWindow) {
const songControls = getSongControls(win); const songControls = getSongControls(win);
protocolHandler = ((cmd: keyof typeof songControls, args: string[] | undefined = undefined) => { protocolHandler = ((
cmd: keyof typeof songControls,
args: string[] | undefined = undefined,
) => {
if (Object.keys(songControls).includes(cmd)) { if (Object.keys(songControls).includes(cmd)) {
songControls[cmd](args as never); songControls[cmd](args as never);
} }
@ -30,7 +35,9 @@ export function handleProtocol(cmd: string, args: string[] | undefined) {
protocolHandler?.(cmd, args); protocolHandler?.(cmd, args);
} }
export function changeProtocolHandler(f: (cmd: string, args: string[] | undefined) => void) { export function changeProtocolHandler(
f: (cmd: string, args: string[] | undefined) => void,
) {
protocolHandler = f; protocolHandler = f;
} }

View File

@ -2,7 +2,11 @@ import { singleton } from './decorators';
import type { YoutubePlayer } from '@/types/youtube-player'; import type { YoutubePlayer } from '@/types/youtube-player';
import type { GetState } from '@/types/datahost-get-state'; import type { GetState } from '@/types/datahost-get-state';
import type { AlbumDetails, PlayerOverlays, VideoDataChangeValue } from '@/types/player-api-events'; import type {
AlbumDetails,
PlayerOverlays,
VideoDataChangeValue,
} from '@/types/player-api-events';
import type { SongInfo } from './song-info'; import type { SongInfo } from './song-info';
import type { VideoDataChanged } from '@/types/video-data-changed'; import type { VideoDataChanged } from '@/types/video-data-changed';
@ -10,9 +14,12 @@ import type { VideoDataChanged } from '@/types/video-data-changed';
let songInfo: SongInfo = {} as SongInfo; let songInfo: SongInfo = {} as SongInfo;
export const getSongInfo = () => songInfo; export const getSongInfo = () => songInfo;
window.ipcRenderer.on('ytmd:update-song-info', (_, extractedSongInfo: SongInfo) => { window.ipcRenderer.on(
'ytmd:update-song-info',
(_, extractedSongInfo: SongInfo) => {
songInfo = extractedSongInfo; songInfo = extractedSongInfo;
}); },
);
// Used because 'loadeddata' or 'loadedmetadata' weren't firing on song start for some users (https://github.com/th-ch/youtube-music/issues/473) // Used because 'loadeddata' or 'loadedmetadata' weren't firing on song start for some users (https://github.com/th-ch/youtube-music/issues/473)
const srcChangedEvent = new CustomEvent('ytmd:src-changed'); const srcChangedEvent = new CustomEvent('ytmd:src-changed');
@ -91,9 +98,8 @@ export const setupFullScreenChangedListener = singleton(() => {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
window.ipcRenderer.send( window.ipcRenderer.send(
'ytmd:fullscreen-changed', 'ytmd:fullscreen-changed',
( (playerBar?.attributes.getNamedItem('player-fullscreened') ?? null) !==
playerBar?.attributes.getNamedItem('player-fullscreened') ?? null null,
) !== null,
); );
}); });
@ -203,15 +209,19 @@ export default (api: YoutubePlayer) => {
if (!isNaN(video.duration)) { if (!isNaN(video.duration)) {
const { const {
title, author, title,
author,
video_id: videoId, video_id: videoId,
list: playlistId list: playlistId,
} = api.getVideoData(); } = api.getVideoData();
const watchNextResponse = api.getWatchNextResponse(); const watchNextResponse = api.getWatchNextResponse();
sendSongInfo({ sendSongInfo({
title, author, videoId, playlistId, title,
author,
videoId,
playlistId,
isUpcoming: false, isUpcoming: false,
lengthSeconds: video.duration, lengthSeconds: video.duration,
@ -236,7 +246,8 @@ export default (api: YoutubePlayer) => {
} else { } else {
playerOverlay = videoData.ytmdWatchNextResponse?.playerOverlays; playerOverlay = videoData.ytmdWatchNextResponse?.playerOverlays;
} }
data.videoDetails.album = playerOverlay?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album?.runs?.at( data.videoDetails.album =
playerOverlay?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album?.runs?.at(
0, 0,
)?.text; )?.text;
data.videoDetails.elapsedSeconds = 0; data.videoDetails.elapsedSeconds = 0;

View File

@ -47,9 +47,7 @@ export interface SongInfo {
export const getImage = async (src: string): Promise<Electron.NativeImage> => { export const getImage = async (src: string): Promise<Electron.NativeImage> => {
const result = await net.fetch(src); const result = await net.fetch(src);
const output = nativeImage.createFromBuffer( const output = nativeImage.createFromBuffer(
Buffer.from( Buffer.from(await result.arrayBuffer()),
await result.arrayBuffer(),
),
); );
if (output.isEmpty() && !src.endsWith('.jpg') && src.includes('.jpg')) { if (output.isEmpty() && !src.endsWith('.jpg') && src.includes('.jpg')) {
// Fix hidden webp files (https://github.com/th-ch/youtube-music/issues/315) // Fix hidden webp files (https://github.com/th-ch/youtube-music/issues/315)

View File

@ -23,7 +23,11 @@ let isPluginLoaded = false;
let isApiLoaded = false; let isApiLoaded = false;
let firstDataLoaded = false; let firstDataLoaded = false;
if (window.trustedTypes && window.trustedTypes.createPolicy && !window.trustedTypes.defaultPolicy) { if (
window.trustedTypes &&
window.trustedTypes.createPolicy &&
!window.trustedTypes.defaultPolicy
) {
window.trustedTypes.createPolicy('default', { window.trustedTypes.createPolicy('default', {
createHTML: (input) => input, createHTML: (input) => input,
createScriptURL: (input) => input, createScriptURL: (input) => input,
@ -48,10 +52,14 @@ interface YouTubeMusicAppElement extends HTMLElement {
async function onApiLoaded() { async function onApiLoaded() {
window.ipcRenderer.on('ytmd:previous-video', () => { window.ipcRenderer.on('ytmd:previous-video', () => {
document.querySelector<HTMLElement>('.previous-button.ytmusic-player-bar')?.click(); document
.querySelector<HTMLElement>('.previous-button.ytmusic-player-bar')
?.click();
}); });
window.ipcRenderer.on('ytmd:next-video', () => { window.ipcRenderer.on('ytmd:next-video', () => {
document.querySelector<HTMLElement>('.next-button.ytmusic-player-bar')?.click(); document
.querySelector<HTMLElement>('.next-button.ytmusic-player-bar')
?.click();
}); });
window.ipcRenderer.on('ytmd:play', (_) => { window.ipcRenderer.on('ytmd:play', (_) => {
api?.playVideo(); api?.playVideo();
@ -66,14 +74,29 @@ async function onApiLoaded() {
window.ipcRenderer.on('ytmd:seek-to', (_, t: number) => api!.seekTo(t)); window.ipcRenderer.on('ytmd:seek-to', (_, t: number) => api!.seekTo(t));
window.ipcRenderer.on('ytmd:seek-by', (_, t: number) => api!.seekBy(t)); window.ipcRenderer.on('ytmd:seek-by', (_, t: number) => api!.seekBy(t));
window.ipcRenderer.on('ytmd:shuffle', () => { window.ipcRenderer.on('ytmd:shuffle', () => {
document.querySelector<HTMLElement & { queue: { shuffle: () => void } }>('ytmusic-player-bar')?.queue.shuffle(); document
}); .querySelector<
window.ipcRenderer.on('ytmd:update-like', (_, status: 'LIKE' | 'DISLIKE' = 'LIKE') => { HTMLElement & { queue: { shuffle: () => void } }
document.querySelector<HTMLElement & { updateLikeStatus: (status: string) => void }>('#like-button-renderer')?.updateLikeStatus(status); >('ytmusic-player-bar')
?.queue.shuffle();
}); });
window.ipcRenderer.on(
'ytmd:update-like',
(_, status: 'LIKE' | 'DISLIKE' = 'LIKE') => {
document
.querySelector<
HTMLElement & { updateLikeStatus: (status: string) => void }
>('#like-button-renderer')
?.updateLikeStatus(status);
},
);
window.ipcRenderer.on('ytmd:switch-repeat', (_, repeat = 1) => { window.ipcRenderer.on('ytmd:switch-repeat', (_, repeat = 1) => {
for (let i = 0; i < repeat; i++) { for (let i = 0; i < repeat; i++) {
document.querySelector<HTMLElement & { onRepeatButtonClick: () => void }>('ytmusic-player-bar')?.onRepeatButtonClick(); document
.querySelector<
HTMLElement & { onRepeatButtonClick: () => void }
>('ytmusic-player-bar')
?.onRepeatButtonClick();
} }
}); });
window.ipcRenderer.on('ytmd:update-volume', (_, volume: number) => { window.ipcRenderer.on('ytmd:update-volume', (_, volume: number) => {
@ -110,12 +133,19 @@ async function onApiLoaded() {
event.sender.send('ytmd:set-fullscreen', isFullscreen()); event.sender.send('ytmd:set-fullscreen', isFullscreen());
}); });
window.ipcRenderer.on('ytmd:click-fullscreen-button', (_, fullscreen: boolean | undefined) => { window.ipcRenderer.on(
'ytmd:click-fullscreen-button',
(_, fullscreen: boolean | undefined) => {
clickFullscreenButton(fullscreen ?? false); clickFullscreenButton(fullscreen ?? false);
}); },
);
window.ipcRenderer.on('ytmd:toggle-mute', (_) => { window.ipcRenderer.on('ytmd:toggle-mute', (_) => {
document.querySelector<HTMLElement & { onVolumeTap: () => void }>('ytmusic-player-bar')?.onVolumeTap(); document
.querySelector<
HTMLElement & { onVolumeTap: () => void }
>('ytmusic-player-bar')
?.onVolumeTap();
}); });
window.ipcRenderer.on('ytmd:get-queue', (event) => { window.ipcRenderer.on('ytmd:get-queue', (event) => {
@ -132,9 +162,7 @@ async function onApiLoaded() {
const audioSource = audioContext.createMediaElementSource(video); const audioSource = audioContext.createMediaElementSource(video);
audioSource.connect(audioContext.destination); audioSource.connect(audioContext.destination);
for await (const [id, plugin] of Object.entries( for (const [id, plugin] of Object.entries(getAllLoadedRendererPlugins())) {
getAllLoadedRendererPlugins(),
)) {
if (typeof plugin.renderer !== 'function') { if (typeof plugin.renderer !== 'function') {
await plugin.renderer?.onPlayerApiReady?.call( await plugin.renderer?.onPlayerApiReady?.call(
plugin.renderer, plugin.renderer,
@ -189,7 +217,9 @@ async function onApiLoaded() {
const itemsSelector = 'ytmusic-guide-section-renderer #items'; const itemsSelector = 'ytmusic-guide-section-renderer #items';
let selector = 'ytmusic-guide-entry-renderer:last-child'; let selector = 'ytmusic-guide-entry-renderer:last-child';
const upgradeBtnIcon = document.querySelector<SVGGElement>('iron-iconset-svg[name="yt-sys-icons"] #youtube_music_monochrome'); const upgradeBtnIcon = document.querySelector<SVGGElement>(
'iron-iconset-svg[name="yt-sys-icons"] #youtube_music_monochrome',
);
if (upgradeBtnIcon) { if (upgradeBtnIcon) {
const path = upgradeBtnIcon.firstChild as SVGPathElement; const path = upgradeBtnIcon.firstChild as SVGPathElement;
const data = path.getAttribute('d')!.substring(0, 15); const data = path.getAttribute('d')!.substring(0, 15);

View File

@ -49,12 +49,18 @@ export const setUpTray = (app: Electron.App, win: Electron.BrowserWindow) => {
const { playPause, next, previous } = getSongControls(win); const { playPause, next, previous } = getSongControls(win);
const pixelRatio = is.windows() ? screen.getPrimaryDisplay().scaleFactor || 1 : 1; const pixelRatio = is.windows()
const defaultTrayIcon = nativeImage.createFromPath(defaultTrayIconAsset).resize({ ? screen.getPrimaryDisplay().scaleFactor || 1
: 1;
const defaultTrayIcon = nativeImage
.createFromPath(defaultTrayIconAsset)
.resize({
width: 16 * pixelRatio, width: 16 * pixelRatio,
height: 16 * pixelRatio, height: 16 * pixelRatio,
}); });
const pausedTrayIcon = nativeImage.createFromPath(pausedTrayIconAsset).resize({ const pausedTrayIcon = nativeImage
.createFromPath(pausedTrayIconAsset)
.resize({
width: 16 * pixelRatio, width: 16 * pixelRatio,
height: 16 * pixelRatio, height: 16 * pixelRatio,
}); });
@ -126,10 +132,12 @@ export const setUpTray = (app: Electron.App, win: Electron.BrowserWindow) => {
return; return;
} }
tray.setToolTip(t('main.tray.tooltip.with-song-info', { tray.setToolTip(
t('main.tray.tooltip.with-song-info', {
artist: songInfo.artist, artist: songInfo.artist,
title: songInfo.title, title: songInfo.title,
})); }),
);
tray.setImage(songInfo.isPaused ? pausedTrayIcon : defaultTrayIcon); tray.setImage(songInfo.isPaused ? pausedTrayIcon : defaultTrayIcon);
} }

View File

@ -7,8 +7,8 @@ import type {
import type { PluginConfig } from '@/types/plugins'; import type { PluginConfig } from '@/types/plugins';
export interface BaseContext<Config extends PluginConfig> { export interface BaseContext<Config extends PluginConfig> {
getConfig(): Promise<Config> | Config; getConfig: () => Promise<Config> | Config;
setConfig(conf: Partial<Omit<Config, 'enabled'>>): Promise<void> | void; setConfig: (conf: Partial<Omit<Config, 'enabled'>>) => Promise<void> | void;
} }
export interface BackendContext<Config extends PluginConfig> export interface BackendContext<Config extends PluginConfig>
@ -29,6 +29,7 @@ export interface MenuContext<Config extends PluginConfig>
refresh: () => Promise<void> | void; refresh: () => Promise<void> | void;
} }
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface PreloadContext<Config extends PluginConfig> export interface PreloadContext<Config extends PluginConfig>
extends BaseContext<Config> {} extends BaseContext<Config> {}

View File

@ -31,6 +31,7 @@ export interface Download {
isLeaderTab: boolean; isLeaderTab: boolean;
} }
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface Entities {} export interface Entities {}
export interface LikeStatus { export interface LikeStatus {

View File

@ -108,6 +108,7 @@ export interface Endpoint {
watchEndpoint: WatchEndpoint; watchEndpoint: WatchEndpoint;
} }
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface CommandMetadata {} export interface CommandMetadata {}
export interface WatchEndpoint { export interface WatchEndpoint {

View File

@ -13,19 +13,16 @@ type Store = {
getState: () => StoreState; getState: () => StoreState;
replaceReducer: (param1: unknown) => unknown; replaceReducer: (param1: unknown) => unknown;
subscribe: (callback: () => void) => unknown; subscribe: (callback: () => void) => unknown;
} };
export type QueueElement = HTMLElement & { export type QueueElement = HTMLElement & {
dispatch(obj: { dispatch(obj: { type: string; payload?: unknown }): void;
type: string;
payload?: unknown;
}): void;
queue: QueueAPI; queue: QueueAPI;
}; };
export type QueueAPI = { export type QueueAPI = {
getItems(): QueueItem[]; getItems(): QueueItem[];
store: { store: {
store: Store, store: Store;
}; };
continuation?: string; continuation?: string;
autoPlaying?: boolean; autoPlaying?: boolean;

View File

@ -297,10 +297,28 @@ export interface YoutubePlayer {
handleGlobalKeyDown: () => void; handleGlobalKeyDown: () => void;
handleGlobalKeyUp: () => void; handleGlobalKeyUp: () => void;
wakeUpControls: () => void; wakeUpControls: () => void;
cueVideoById: (videoId: string, startSeconds: number, suggestedQuality: string) => void; cueVideoById: (
loadVideoById: (videoId: string, startSeconds: number, suggestedQuality: string) => void; videoId: string,
cueVideoByUrl: (mediaContentUrl: string, startSeconds: number, suggestedQuality: string, playerType: string) => void; startSeconds: number,
loadVideoByUrl: (mediaContentUrl: string, startSeconds: number, suggestedQuality: string, playerType: string) => void; suggestedQuality: string,
) => void;
loadVideoById: (
videoId: string,
startSeconds: number,
suggestedQuality: string,
) => void;
cueVideoByUrl: (
mediaContentUrl: string,
startSeconds: number,
suggestedQuality: string,
playerType: string,
) => void;
loadVideoByUrl: (
mediaContentUrl: string,
startSeconds: number,
suggestedQuality: string,
playerType: string,
) => void;
/** /**
* Note: This doesn't resume playback, it plays from the start. * Note: This doesn't resume playback, it plays from the start.
*/ */
@ -361,7 +379,7 @@ export interface YoutubePlayer {
name: K extends 'videodatachange' ? PlayerAPIEvents[K]['name'] : never, name: K extends 'videodatachange' ? PlayerAPIEvents[K]['name'] : never,
data: K extends 'videodatachange' ? PlayerAPIEvents[K]['value'] : never, data: K extends 'videodatachange' ? PlayerAPIEvents[K]['value'] : never,
) => void, ) => void,
options?: boolean | AddEventListenerOptions | undefined, options?: boolean | AddEventListenerOptions,
) => void; ) => void;
removeEventListener: <K extends keyof PlayerAPIEvents>( removeEventListener: <K extends keyof PlayerAPIEvents>(
type: K, type: K,
@ -370,7 +388,7 @@ export interface YoutubePlayer {
name: K extends 'videodatachange' ? PlayerAPIEvents[K]['name'] : never, name: K extends 'videodatachange' ? PlayerAPIEvents[K]['name'] : never,
data: K extends 'videodatachange' ? PlayerAPIEvents[K]['value'] : never, data: K extends 'videodatachange' ? PlayerAPIEvents[K]['value'] : never,
) => void, ) => void,
options?: boolean | EventListenerOptions | undefined, options?: boolean | EventListenerOptions,
) => void; ) => void;
getDebugText: () => string; getDebugText: () => string;
addCueRange: <Parameters extends unknown[], Return>( addCueRange: <Parameters extends unknown[], Return>(
@ -395,7 +413,11 @@ export interface YoutubePlayer {
getMediaReferenceTime: () => number; getMediaReferenceTime: () => number;
getSize: () => { width: number; height: number }; getSize: () => { width: number; height: number };
logImaAdEvent: (eventType: unknown, breakType: unknown) => void; logImaAdEvent: (eventType: unknown, breakType: unknown) => void;
preloadVideoById: (videoId: string, startSeconds: number, suggestedQuality: string) => void; preloadVideoById: (
videoId: string,
startSeconds: number,
suggestedQuality: string,
) => void;
setAccountLinkState: <Parameters extends unknown[], Return>( setAccountLinkState: <Parameters extends unknown[], Return>(
...params: Parameters ...params: Parameters
) => Return; ) => Return;

View File

@ -1,4 +1,6 @@
export const waitForElement = <T extends Element>(selector: string): Promise<T> => { export const waitForElement = <T extends Element>(
selector: string,
): Promise<T> => {
return new Promise<T>((resolve) => { return new Promise<T>((resolve) => {
const interval = setInterval(() => { const interval = setInterval(() => {
const elem = document.querySelector<T>(selector); const elem = document.querySelector<T>(selector);

View File

@ -31,7 +31,8 @@ declare module 'solid-js' {
interface IntrinsicElements { interface IntrinsicElements {
'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps; 'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps;
'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps; 'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps;
'tp-yt-paper-spinner-lite': ComponentProps<'div'> & YpYtPaperSpinnerLiteProps; 'tp-yt-paper-spinner-lite': ComponentProps<'div'> &
YpYtPaperSpinnerLiteProps;
} }
} }
} }

View File

@ -17,9 +17,7 @@ export const i18nImporter = () => {
}); });
const srcPath = resolve(__dirname, '..', 'src'); const srcPath = resolve(__dirname, '..', 'src');
const plugins = globSync([ const plugins = globSync(['src/i18n/resources/*.json']).map((path) => {
'src/i18n/resources/*.json',
]).map((path) => {
const nameWithExt = basename(path); const nameWithExt = basename(path);
const name = nameWithExt.replace(extname(nameWithExt), ''); const name = nameWithExt.replace(extname(nameWithExt), '');

View File

@ -49,7 +49,9 @@ export const pluginVirtualModuleGenerator = (
for (const { name } of plugins) { for (const { name } of plugins) {
const checkMode = mode === 'main' ? 'backend' : mode; const checkMode = mode === 'main' ? 'backend' : mode;
// HACK: To avoid situation like importing renderer plugins in main // HACK: To avoid situation like importing renderer plugins in main
writer.writeLine(` ...(${snakeToCamel(name)}Plugin['${checkMode}'] ? { "${name}": ${snakeToCamel(name)}Plugin } : {}),`); writer.writeLine(
` ...(${snakeToCamel(name)}Plugin['${checkMode}'] ? { "${name}": ${snakeToCamel(name)}Plugin } : {}),`,
);
} }
writer.writeLine('};'); writer.writeLine('};');
writer.blankLine(); writer.blankLine();

View File

@ -3,11 +3,18 @@ import { resolve, basename, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { createFilter } from 'vite'; import { createFilter } from 'vite';
import { Project, ts, ObjectLiteralExpression, VariableDeclarationKind } from 'ts-morph'; import {
Project,
ts,
ObjectLiteralExpression,
VariableDeclarationKind,
} from 'ts-morph';
import type { PluginOption } from 'vite'; import type { PluginOption } from 'vite';
export default function (mode: 'backend' | 'preload' | 'renderer' | 'none'): PluginOption { export default function (
mode: 'backend' | 'preload' | 'renderer' | 'none',
): PluginOption {
const pluginFilter = createFilter([ const pluginFilter = createFilter([
'src/plugins/*/index.{js,ts}', 'src/plugins/*/index.{js,ts}',
'src/plugins/*', 'src/plugins/*',
@ -100,13 +107,16 @@ export default function (mode: 'backend' | 'preload' | 'renderer' | 'none'): Plu
objExpr.getProperty(propertyNames[index])?.remove(); objExpr.getProperty(propertyNames[index])?.remove();
} }
const stubObjExpr = src.addVariableStatement({ const stubObjExpr = src
.addVariableStatement({
isExported: true, isExported: true,
declarationKind: VariableDeclarationKind.Const, declarationKind: VariableDeclarationKind.Const,
declarations: [{ declarations: [
{
name: 'pluginStub', name: 'pluginStub',
initializer: (writer) => writer.write(objExpr!.getText()), initializer: (writer) => writer.write(objExpr.getText()),
}] },
],
}) })
.getDeclarations()[0] .getDeclarations()[0]
.getInitializer() as ObjectLiteralExpression; .getInitializer() as ObjectLiteralExpression;