mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 10:11:46 +00:00
fix: apply fix from eslint
This commit is contained in:
@ -12,7 +12,7 @@ export default tsEslint.config(
|
||||
tsEslint.configs.eslintRecommended,
|
||||
...tsEslint.configs.recommendedTypeChecked,
|
||||
prettier,
|
||||
{ ignores: ['dist', 'node_modules', '*.config.*js'] },
|
||||
{ ignores: ['dist', 'node_modules', '*.config.*js', '*.test.*js'] },
|
||||
{
|
||||
plugins: {
|
||||
stylistic,
|
||||
@ -54,7 +54,7 @@ export default tsEslint.config(
|
||||
afterLineComment: false,
|
||||
}],
|
||||
'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-tabs': 'error',
|
||||
'no-void': 'error',
|
||||
|
||||
38
src/custom-electron-prompt.d.ts
vendored
38
src/custom-electron-prompt.d.ts
vendored
@ -64,29 +64,29 @@ declare module 'custom-electron-prompt' {
|
||||
export type PromptOptions<T extends string> = T extends 'input'
|
||||
? InputPromptOptions
|
||||
: T extends 'select'
|
||||
? SelectPromptOptions
|
||||
: T extends 'counter'
|
||||
? CounterPromptOptions
|
||||
: T extends 'keybind'
|
||||
? KeybindPromptOptions
|
||||
: T extends 'multiInput'
|
||||
? MultiInputPromptOptions
|
||||
: never;
|
||||
? SelectPromptOptions
|
||||
: T extends 'counter'
|
||||
? CounterPromptOptions
|
||||
: T extends 'keybind'
|
||||
? KeybindPromptOptions
|
||||
: T extends 'multiInput'
|
||||
? MultiInputPromptOptions
|
||||
: never;
|
||||
|
||||
type PromptResult<T extends string> = T extends 'input'
|
||||
? string
|
||||
: T extends 'select'
|
||||
? string
|
||||
: T extends 'counter'
|
||||
? number
|
||||
: T extends 'keybind'
|
||||
? {
|
||||
value: string;
|
||||
accelerator: string;
|
||||
}[]
|
||||
: T extends 'multiInput'
|
||||
? string[]
|
||||
: never;
|
||||
? string
|
||||
: T extends 'counter'
|
||||
? number
|
||||
: T extends 'keybind'
|
||||
? {
|
||||
value: string;
|
||||
accelerator: string;
|
||||
}[]
|
||||
: T extends 'multiInput'
|
||||
? string[]
|
||||
: never;
|
||||
|
||||
const prompt: <T extends Type>(
|
||||
options?: PromptOptions<T> & { type: T },
|
||||
|
||||
30
src/index.ts
30
src/index.ts
@ -334,7 +334,9 @@ async function createMainWindow() {
|
||||
const display = screen.getDisplayNearestPoint(windowPosition);
|
||||
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 scaledHeight = Math.floor(windowSize.height * scaleFactor);
|
||||
|
||||
@ -342,10 +344,10 @@ async function createMainWindow() {
|
||||
const scaledY = windowY;
|
||||
|
||||
if (
|
||||
scaledX + (scaledWidth / 2) < display.bounds.x - 8 || // Left
|
||||
scaledX + (scaledWidth / 2) > display.bounds.x + display.bounds.width || // Right
|
||||
scaledX + scaledWidth / 2 < display.bounds.x - 8 || // Left
|
||||
scaledX + scaledWidth / 2 > display.bounds.x + display.bounds.width || // Right
|
||||
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
|
||||
if (is.dev()) {
|
||||
@ -442,7 +444,7 @@ async function createMainWindow() {
|
||||
...defaultTitleBarOverlayOptions,
|
||||
height: Math.floor(
|
||||
defaultTitleBarOverlayOptions.height! *
|
||||
win.webContents.getZoomFactor(),
|
||||
win.webContents.getZoomFactor(),
|
||||
),
|
||||
});
|
||||
}
|
||||
@ -455,7 +457,7 @@ async function createMainWindow() {
|
||||
event.preventDefault();
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -479,8 +481,8 @@ app.once('browser-window-created', (_event, win) => {
|
||||
const updatedUserAgent = is.macOS()
|
||||
? userAgents.mac
|
||||
: is.windows()
|
||||
? userAgents.windows
|
||||
: userAgents.linux;
|
||||
? userAgents.windows
|
||||
: userAgents.linux;
|
||||
|
||||
win.webContents.userAgent = updatedUserAgent;
|
||||
app.userAgentFallback = updatedUserAgent;
|
||||
@ -642,7 +644,9 @@ app.whenReady().then(async () => {
|
||||
// In dev mode, get string from process.env.VITE_DEV_SERVER_URL, else use fs.readFileSync
|
||||
if (is.dev() && process.env.ELECTRON_RENDERER_URL) {
|
||||
// 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');
|
||||
(async () => {
|
||||
await new Promise((resolve) => {
|
||||
@ -663,7 +667,8 @@ app.whenReady().then(async () => {
|
||||
document.body.appendChild(rendererScript);
|
||||
})();
|
||||
0
|
||||
`];
|
||||
`,
|
||||
];
|
||||
} else {
|
||||
const rendererPath = path.join(__dirname, '..', 'renderer');
|
||||
const indexHTML = parse(
|
||||
@ -675,7 +680,10 @@ app.whenReady().then(async () => {
|
||||
scriptSrc.getAttribute('src')!,
|
||||
);
|
||||
const scriptString = fs.readFileSync(scriptPath, 'utf-8');
|
||||
event.returnValue = [url.pathToFileURL(scriptPath).toString(), scriptString + ';0'];
|
||||
event.returnValue = [
|
||||
url.pathToFileURL(scriptPath).toString(),
|
||||
scriptString + ';0',
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -34,11 +34,12 @@ const createContext = (
|
||||
win.webContents.send(event, ...args);
|
||||
},
|
||||
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));
|
||||
},
|
||||
on: (event: string, listener: CallableFunction) => {
|
||||
ipcMain.on(event, (_, ...args: unknown[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
listener(...args);
|
||||
});
|
||||
},
|
||||
@ -75,11 +76,11 @@ export const forceUnloadMainPlugin = async (
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
console.log(
|
||||
LoggerPrefix,
|
||||
t('common.console.plugins.unload-failed', { pluginName: id }),
|
||||
);
|
||||
return Promise.reject();
|
||||
const message = t('common.console.plugins.unload-failed', {
|
||||
pluginName: id,
|
||||
});
|
||||
console.log(LoggerPrefix, message);
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@ -87,7 +88,7 @@ export const forceUnloadMainPlugin = async (
|
||||
t('common.console.plugins.unload-failed', { pluginName: id }),
|
||||
);
|
||||
console.trace(err);
|
||||
return Promise.reject(err);
|
||||
return Promise.reject(err as Error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -111,11 +112,11 @@ export const forceLoadMainPlugin = async (
|
||||
) {
|
||||
loadedPluginMap[id] = plugin;
|
||||
} else {
|
||||
console.log(
|
||||
LoggerPrefix,
|
||||
t('common.console.plugins.load-failed', { pluginName: id }),
|
||||
);
|
||||
return Promise.reject();
|
||||
const message = t('common.console.plugins.load-failed', {
|
||||
pluginName: id,
|
||||
});
|
||||
console.log(LoggerPrefix, message);
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@ -123,7 +124,7 @@ export const forceLoadMainPlugin = async (
|
||||
t('common.console.plugins.initialize-failed', { pluginName: id }),
|
||||
);
|
||||
console.trace(err);
|
||||
return Promise.reject(err);
|
||||
return Promise.reject(err as Error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -18,7 +18,8 @@ const loadedPluginMap: Record<
|
||||
export const createContext = <Config extends PluginConfig>(
|
||||
id: string,
|
||||
): 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) => {
|
||||
await window.ipcRenderer.invoke('ytmd:set-config', id, newConfig);
|
||||
},
|
||||
@ -30,6 +31,7 @@ export const createContext = <Config extends PluginConfig>(
|
||||
window.ipcRenderer.invoke(event, ...args),
|
||||
on: (event: string, listener: CallableFunction) => {
|
||||
window.ipcRenderer.on(event, (_, ...args: unknown[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
listener(...args);
|
||||
});
|
||||
},
|
||||
|
||||
157
src/menu.ts
157
src/menu.ts
@ -1,5 +1,13 @@
|
||||
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 { satisfies } from 'semver';
|
||||
|
||||
@ -68,12 +76,21 @@ export const mainMenuTemplate = async (
|
||||
const plugin = allPlugins[id];
|
||||
const pluginLabel = plugin?.name?.() ?? id;
|
||||
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)) {
|
||||
return [
|
||||
id,
|
||||
pluginEnabledMenu(id, pluginLabel, pluginDescription, isNew, true, innerRefreshMenu),
|
||||
pluginEnabledMenu(
|
||||
id,
|
||||
pluginLabel,
|
||||
pluginDescription,
|
||||
isNew,
|
||||
true,
|
||||
innerRefreshMenu,
|
||||
),
|
||||
] as const;
|
||||
}
|
||||
|
||||
@ -115,9 +132,18 @@ export const mainMenuTemplate = async (
|
||||
const plugin = allPlugins[id];
|
||||
const pluginLabel = plugin?.name?.() ?? id;
|
||||
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);
|
||||
@ -229,12 +255,12 @@ export const mainMenuTemplate = async (
|
||||
submenu: [
|
||||
...((config.get('options.themes')?.length ?? 0) === 0
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme',
|
||||
),
|
||||
}
|
||||
]
|
||||
{
|
||||
label: t(
|
||||
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme',
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(config.get('options.themes')?.map((theme: string) => ({
|
||||
type: 'normal' as const,
|
||||
@ -251,16 +277,25 @@ export const mainMenuTemplate = async (
|
||||
{ theme },
|
||||
),
|
||||
buttons: [
|
||||
t('main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.cancel'),
|
||||
t('main.menu.options.submenu.visual-tweaks.submenu.theme.dialog.button.remove'),
|
||||
t(
|
||||
'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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
},
|
||||
})) ?? []),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
@ -306,40 +341,40 @@ export const mainMenuTemplate = async (
|
||||
},
|
||||
...((is.windows() || is.linux()
|
||||
? [
|
||||
{
|
||||
label: t('main.menu.options.submenu.hide-menu.label'),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.hideMenu'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.hideMenu', item.checked);
|
||||
if (item.checked && !config.get('options.hideMenuWarned')) {
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: t(
|
||||
'main.menu.options.submenu.hide-menu.dialog.title',
|
||||
),
|
||||
message: t(
|
||||
'main.menu.options.submenu.hide-menu.dialog.message',
|
||||
),
|
||||
});
|
||||
}
|
||||
{
|
||||
label: t('main.menu.options.submenu.hide-menu.label'),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.hideMenu'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.hideMenu', item.checked);
|
||||
if (item.checked && !config.get('options.hideMenuWarned')) {
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: t(
|
||||
'main.menu.options.submenu.hide-menu.dialog.title',
|
||||
),
|
||||
message: t(
|
||||
'main.menu.options.submenu.hide-menu.dialog.message',
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
]
|
||||
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||
...((is.windows() || is.macOS()
|
||||
? // Only works on Win/Mac
|
||||
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
||||
[
|
||||
{
|
||||
label: t('main.menu.options.submenu.start-at-login'),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.startAtLogin'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.startAtLogin', item.checked);
|
||||
[
|
||||
{
|
||||
label: t('main.menu.options.submenu.start-at-login'),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.startAtLogin'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.startAtLogin', item.checked);
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
]
|
||||
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||
{
|
||||
label: t('main.menu.options.submenu.tray.label'),
|
||||
@ -493,25 +528,25 @@ export const mainMenuTemplate = async (
|
||||
{ type: 'separator' },
|
||||
is.macOS()
|
||||
? {
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
|
||||
),
|
||||
// Cannot use "toggleDevTools" role in macOS
|
||||
click() {
|
||||
const { webContents } = win;
|
||||
if (webContents.isDevToolsOpened()) {
|
||||
webContents.closeDevTools();
|
||||
} else {
|
||||
webContents.openDevTools();
|
||||
}
|
||||
},
|
||||
}
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
|
||||
),
|
||||
// Cannot use "toggleDevTools" role in macOS
|
||||
click() {
|
||||
const { webContents } = win;
|
||||
if (webContents.isDevToolsOpened()) {
|
||||
webContents.closeDevTools();
|
||||
} else {
|
||||
webContents.openDevTools();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
|
||||
),
|
||||
role: 'toggleDevTools',
|
||||
},
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
|
||||
),
|
||||
role: 'toggleDevTools',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.edit-config-json',
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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) {
|
||||
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');
|
||||
if (!player) return;
|
||||
|
||||
@ -53,4 +55,4 @@ export const loadAdSpeedup = async () => {
|
||||
player.classList.contains('ad-interrupting');
|
||||
speedUpAndMute(player, isAdShowing);
|
||||
skipAd(player);
|
||||
}
|
||||
};
|
||||
|
||||
@ -79,7 +79,7 @@ export default createPlugin({
|
||||
if (config.blocker === blockers.AdSpeedup) {
|
||||
await loadAdSpeedup();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
backend: {
|
||||
mainWindow: null as BrowserWindow | null,
|
||||
|
||||
@ -104,21 +104,28 @@ export default createPlugin<
|
||||
buttons.splice(i, 1);
|
||||
i--;
|
||||
} else {
|
||||
(buttons[i].children[0].children[0] as HTMLElement).style.setProperty(
|
||||
(
|
||||
buttons[i].children[0].children[0] as HTMLElement
|
||||
).style.setProperty(
|
||||
'-webkit-mask-size',
|
||||
`100% ${100 - ((count / listsLength) * 100)}%`,
|
||||
`100% ${100 - (count / listsLength) * 100}%`,
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
const menuParent = document.querySelector('#action-buttons')?.parentElement;
|
||||
const menuParent =
|
||||
document.querySelector('#action-buttons')?.parentElement;
|
||||
if (menuParent && !document.querySelector('.like-menu')) {
|
||||
const menu = document.createElement('div');
|
||||
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) {
|
||||
menu.appendChild(button);
|
||||
button.addEventListener('click', this.loadFullList);
|
||||
|
||||
@ -25,7 +25,12 @@ export default createPlugin<
|
||||
sidebarSmall: 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;
|
||||
},
|
||||
{
|
||||
@ -91,7 +96,10 @@ export default createPlugin<
|
||||
this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
||||
|
||||
const config = await getConfig();
|
||||
document.documentElement.style.setProperty(RATIO_KEY, `${~~(config.ratio * 100)}%`);
|
||||
document.documentElement.style.setProperty(
|
||||
RATIO_KEY,
|
||||
`${~~(config.ratio * 100)}%`,
|
||||
);
|
||||
},
|
||||
onPlayerApiReady(playerApi) {
|
||||
const fastAverageColor = new FastAverageColor();
|
||||
@ -100,10 +108,12 @@ export default createPlugin<
|
||||
if (event.detail.name !== 'dataloaded') return;
|
||||
|
||||
const playerResponse = playerApi.getPlayerResponse();
|
||||
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
|
||||
const thumbnail =
|
||||
playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
|
||||
if (!thumbnail) return;
|
||||
|
||||
const albumColor = await fastAverageColor.getColorAsync(thumbnail.url)
|
||||
const albumColor = await fastAverageColor
|
||||
.getColorAsync(thumbnail.url)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
return null;
|
||||
@ -120,8 +130,14 @@ export default createPlugin<
|
||||
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(DARK_COLOR_KEY, `${~~this.darkColor.red()}, ${~~this.darkColor.green()}, ${~~this.darkColor.blue()}`);
|
||||
document.documentElement.style.setProperty(
|
||||
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 {
|
||||
document.documentElement.style.setProperty(COLOR_KEY, '0, 0, 0');
|
||||
document.documentElement.style.setProperty(DARK_COLOR_KEY, '0, 0, 0');
|
||||
@ -131,7 +147,10 @@ export default createPlugin<
|
||||
});
|
||||
},
|
||||
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) {
|
||||
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)',
|
||||
};
|
||||
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.documentElement.style.setProperty('--ytmusic-background', this.getMixedColor('#030303', DARK_COLOR_KEY), 'important');
|
||||
document.body.style.setProperty(
|
||||
'background',
|
||||
this.getMixedColor('#030303', COLOR_KEY),
|
||||
'important',
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--ytmusic-background',
|
||||
this.getMixedColor('#030303', DARK_COLOR_KEY),
|
||||
'important',
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -53,10 +53,16 @@ export default createPlugin({
|
||||
|
||||
const songImage = document.querySelector<HTMLImageElement>('#song-image');
|
||||
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
|
||||
const image = songImage?.querySelector<HTMLImageElement>('yt-img-shadow > img');
|
||||
const video = await waitForElement<HTMLVideoElement>('.html5-video-container > video');
|
||||
const image = songImage?.querySelector<HTMLImageElement>(
|
||||
'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 = () => {
|
||||
if (!songImage || !image) return null;
|
||||
@ -95,7 +101,9 @@ export default createPlugin({
|
||||
const blurCanvas = document.createElement('canvas');
|
||||
blurCanvas.classList.add('html5-blur-canvas');
|
||||
|
||||
const context = blurCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const context = blurCanvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
|
||||
/* effect */
|
||||
let lastEffectWorkId: number | null = null;
|
||||
@ -109,14 +117,18 @@ export default createPlugin({
|
||||
if (!context) return;
|
||||
|
||||
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 (!height) return;
|
||||
|
||||
context.globalAlpha = 1;
|
||||
if (lastImageData) {
|
||||
const frameOffset = (1 / this.buffer) * (1000 / this.interpolationTime);
|
||||
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1
|
||||
const frameOffset =
|
||||
(1 / this.buffer) * (1000 / this.interpolationTime);
|
||||
context.globalAlpha = 1 - frameOffset * 2; // because of alpha value must be < 1
|
||||
context.putImageData(lastImageData, 0, 0);
|
||||
context.globalAlpha = frameOffset;
|
||||
}
|
||||
@ -137,7 +149,9 @@ export default createPlugin({
|
||||
if (newWidth === 0 || newHeight === 0) return;
|
||||
|
||||
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');
|
||||
else blurCanvas.classList.remove('fullscreen');
|
||||
@ -151,7 +165,10 @@ export default createPlugin({
|
||||
|
||||
/* hooking */
|
||||
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 = () => {
|
||||
if (canvasInterval) clearInterval(canvasInterval);
|
||||
@ -159,7 +176,10 @@ export default createPlugin({
|
||||
};
|
||||
const onPlay = () => {
|
||||
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('play', onPlay);
|
||||
@ -198,11 +218,20 @@ export default createPlugin({
|
||||
if (isPageOpen) {
|
||||
const isVideo = isVideoMode();
|
||||
if (!force) {
|
||||
if (this.lastMediaType === 'video' && this.lastVideoSource === video?.src) return false;
|
||||
if (this.lastMediaType === 'image' && this.lastImageSource === image?.src) return false;
|
||||
if (
|
||||
this.lastMediaType === 'video' &&
|
||||
this.lastVideoSource === video?.src
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
this.lastMediaType === 'image' &&
|
||||
this.lastImageSource === image?.src
|
||||
)
|
||||
return false;
|
||||
}
|
||||
this.unregister?.();
|
||||
this.unregister = (isVideo ? injectBlurVideo() : injectBlurImage()) ?? null;
|
||||
this.unregister =
|
||||
(isVideo ? injectBlurVideo() : injectBlurImage()) ?? null;
|
||||
} else {
|
||||
this.unregister?.();
|
||||
this.unregister = null;
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
import { t } from "@/i18n";
|
||||
import { MenuContext } from "@/types/contexts";
|
||||
import { MenuItemConstructorOptions } from "electron";
|
||||
import { AmbientModePluginConfig } from "./types";
|
||||
import { MenuItemConstructorOptions } from 'electron';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import { MenuContext } from '@/types/contexts';
|
||||
import { AmbientModePluginConfig } from './types';
|
||||
|
||||
export interface menuParameters {
|
||||
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 qualityList = [10, 25, 50, 100, 200, 500, 1000];
|
||||
const sizeList = [100, 110, 125, 150, 175, 200, 300];
|
||||
@ -107,4 +117,4 @@ export const menu: (ctx: MenuContext<AmbientModePluginConfig>) => MenuItemConstr
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
@ -7,4 +7,4 @@ export type AmbientModePluginConfig = {
|
||||
size: number;
|
||||
opacity: number;
|
||||
fullscreen: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@ -28,7 +28,10 @@ export const backend = createBackend<BackendType, APIServerConfig>({
|
||||
this.end();
|
||||
},
|
||||
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;
|
||||
return;
|
||||
}
|
||||
@ -55,7 +58,8 @@ export const backend = createBackend<BackendType, APIServerConfig>({
|
||||
this.app.use('/api/*', async (ctx, next) => {
|
||||
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) {
|
||||
ctx.status(401);
|
||||
return ctx.body('Unauthorized');
|
||||
|
||||
@ -2,9 +2,10 @@ import { createRoute, z } from '@hono/zod-openapi';
|
||||
import { dialog } from 'electron';
|
||||
import { sign } from 'hono/jwt';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { APIServerConfig } from '../../config';
|
||||
import { JWTPayload } from '../scheme';
|
||||
|
||||
@ -20,7 +21,7 @@ const routes = {
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
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) => {
|
||||
const config = await getConfig();
|
||||
const { id } = ctx.req.param();
|
||||
@ -54,7 +58,10 @@ export const register = (app: HonoApp, { getConfig, setConfig }: BackendContext<
|
||||
origin: getConnInfo(ctx).remote.address,
|
||||
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,
|
||||
cancelId: 1,
|
||||
});
|
||||
@ -68,10 +75,7 @@ export const register = (app: HonoApp, { getConfig, setConfig }: BackendContext<
|
||||
}
|
||||
|
||||
setConfig({
|
||||
authorizedClients: [
|
||||
...config.authorizedClients,
|
||||
id,
|
||||
],
|
||||
authorizedClients: [...config.authorizedClients, id],
|
||||
});
|
||||
|
||||
const token = await sign(
|
||||
|
||||
@ -84,7 +84,8 @@ const routes = {
|
||||
method: 'post',
|
||||
path: `/api/${API_VERSION}/toggle-play`,
|
||||
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: {
|
||||
headers: AuthHeadersSchema,
|
||||
},
|
||||
@ -280,7 +281,7 @@ const routes = {
|
||||
schema: z.object({
|
||||
state: z.boolean(),
|
||||
}),
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -299,7 +300,7 @@ const routes = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({}),
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
204: {
|
||||
@ -321,7 +322,7 @@ const routes = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: SongInfoSchema,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
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);
|
||||
|
||||
app.openapi(routes.previous, (ctx) => {
|
||||
@ -426,9 +431,12 @@ export const register = (app: HonoApp, { window }: BackendContext<APIServerConfi
|
||||
|
||||
app.openapi(routes.getFullscreenState, async (ctx) => {
|
||||
const stateResponsePromise = new Promise<boolean>((resolve) => {
|
||||
ipcMain.once('ytmd:set-fullscreen', (_, isFullscreen: boolean | undefined) => {
|
||||
return resolve(!!isFullscreen);
|
||||
});
|
||||
ipcMain.once(
|
||||
'ytmd:set-fullscreen',
|
||||
(_, isFullscreen: boolean | undefined) => {
|
||||
return resolve(!!isFullscreen);
|
||||
},
|
||||
);
|
||||
|
||||
controller.requestFullscreenInformation();
|
||||
});
|
||||
|
||||
@ -5,4 +5,3 @@ export * from './go-forward';
|
||||
export * from './switch-repeat';
|
||||
export * from './set-volume';
|
||||
export * from './set-fullscreen';
|
||||
|
||||
|
||||
@ -22,17 +22,20 @@ export const onMenu = async ({
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
|
||||
const newHostname = await prompt(
|
||||
{
|
||||
title: t('plugins.api-server.prompt.hostname.title'),
|
||||
label: t('plugins.api-server.prompt.hostname.label'),
|
||||
value: config.hostname,
|
||||
type: 'input',
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
) ?? (config.hostname ?? defaultAPIServerConfig.hostname);
|
||||
const newHostname =
|
||||
(await prompt(
|
||||
{
|
||||
title: t('plugins.api-server.prompt.hostname.title'),
|
||||
label: t('plugins.api-server.prompt.hostname.label'),
|
||||
value: config.hostname,
|
||||
type: 'input',
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
)) ??
|
||||
config.hostname ??
|
||||
defaultAPIServerConfig.hostname;
|
||||
|
||||
setConfig({ ...config, hostname: newHostname });
|
||||
},
|
||||
@ -43,18 +46,21 @@ export const onMenu = async ({
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
|
||||
const newPort = await prompt(
|
||||
{
|
||||
title: t('plugins.api-server.prompt.port.title'),
|
||||
label: t('plugins.api-server.prompt.port.label'),
|
||||
value: config.port,
|
||||
type: 'counter',
|
||||
counterOptions: { minimum: 0, maximum: 65565, },
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
) ?? (config.port ?? defaultAPIServerConfig.port);
|
||||
const newPort =
|
||||
(await prompt(
|
||||
{
|
||||
title: t('plugins.api-server.prompt.port.title'),
|
||||
label: t('plugins.api-server.prompt.port.label'),
|
||||
value: config.port,
|
||||
type: 'counter',
|
||||
counterOptions: { minimum: 0, maximum: 65565 },
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
)) ??
|
||||
config.port ??
|
||||
defaultAPIServerConfig.port;
|
||||
|
||||
setConfig({ ...config, port: newPort });
|
||||
},
|
||||
@ -64,7 +70,9 @@ export const onMenu = async ({
|
||||
type: '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',
|
||||
checked: config.authStrategy === 'AUTH_AT_FIRST',
|
||||
click() {
|
||||
|
||||
@ -15,7 +15,10 @@ export default createPlugin({
|
||||
this.styleSheet = new CSSStyleSheet();
|
||||
await this.styleSheet.replace(style);
|
||||
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.styleSheet];
|
||||
document.adoptedStyleSheets = [
|
||||
...document.adoptedStyleSheets,
|
||||
this.styleSheet,
|
||||
];
|
||||
},
|
||||
async stop() {
|
||||
await this.styleSheet?.replace('');
|
||||
|
||||
@ -34,7 +34,7 @@ export default createPlugin<
|
||||
{
|
||||
label: t('plugins.captions-selector.menu.autoload'),
|
||||
type: 'checkbox',
|
||||
checked: config.autoload as boolean,
|
||||
checked: config.autoload,
|
||||
click(item) {
|
||||
setConfig({ autoload: item.checked });
|
||||
},
|
||||
@ -42,7 +42,7 @@ export default createPlugin<
|
||||
{
|
||||
label: t('plugins.captions-selector.menu.disable-captions'),
|
||||
type: 'checkbox',
|
||||
checked: config.disableCaptions as boolean,
|
||||
checked: config.disableCaptions,
|
||||
click(item) {
|
||||
setConfig({ disableCaptions: item.checked });
|
||||
},
|
||||
|
||||
@ -64,7 +64,7 @@ interface VolumeFade {
|
||||
// Main class
|
||||
export class VolumeFader {
|
||||
private readonly media: HTMLMediaElement;
|
||||
private readonly logger: VolumeLogger | false;
|
||||
private readonly logger: VolumeLogger | null;
|
||||
private scale: {
|
||||
internalToVolume: (level: number) => number;
|
||||
volumeToInternal: (level: number) => number;
|
||||
@ -100,7 +100,7 @@ export class VolumeFader {
|
||||
this.logger = options.logger;
|
||||
} else {
|
||||
// Set log function explicitly to false
|
||||
this.logger = false;
|
||||
this.logger = null;
|
||||
}
|
||||
|
||||
// Linear volume fading?
|
||||
@ -112,7 +112,7 @@ export class VolumeFader {
|
||||
};
|
||||
|
||||
// Log setting
|
||||
this.logger && this.logger('Using linear fading.');
|
||||
this.logger?.('Using linear fading.');
|
||||
}
|
||||
// No linear, but logarithmic fading…
|
||||
else {
|
||||
@ -152,9 +152,8 @@ export class VolumeFader {
|
||||
};
|
||||
|
||||
// Log setting if not default
|
||||
options.fadeScaling &&
|
||||
this.logger &&
|
||||
this.logger(
|
||||
if (options.fadeScaling)
|
||||
this.logger?.(
|
||||
'Using logarithmic fading with ' +
|
||||
String(10 * dynamicRange) +
|
||||
' dB dynamic range.',
|
||||
@ -170,8 +169,7 @@ export class VolumeFader {
|
||||
this.media.volume = options.initialVolume;
|
||||
|
||||
// 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?
|
||||
@ -187,7 +185,7 @@ export class VolumeFader {
|
||||
this.active = false;
|
||||
|
||||
// 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;
|
||||
|
||||
// Log setting
|
||||
this.logger &&
|
||||
this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
|
||||
this.logger?.('Set fade duration to ' + String(fadeDuration) + ' ms.');
|
||||
} else {
|
||||
// Abort and throw an exception
|
||||
throw new TypeError('Positive number expected as fade duration!');
|
||||
@ -279,7 +276,7 @@ export class VolumeFader {
|
||||
this.start();
|
||||
|
||||
// Log new fade
|
||||
this.logger && this.logger('New fade started:', this.fade);
|
||||
this.logger?.('New fade started:', this.fade);
|
||||
|
||||
// Return instance for chaining
|
||||
return this;
|
||||
@ -313,7 +310,7 @@ export class VolumeFader {
|
||||
|
||||
// Compute current level on internal scale
|
||||
const level =
|
||||
(progress * (this.fade.volume.end - this.fade.volume.start)) +
|
||||
progress * (this.fade.volume.end - this.fade.volume.start) +
|
||||
this.fade.volume.start;
|
||||
|
||||
// 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));
|
||||
} else {
|
||||
// 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
|
||||
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
|
||||
@ -333,7 +329,7 @@ export class VolumeFader {
|
||||
this.active = false;
|
||||
|
||||
// Done, call back (if callable)
|
||||
typeof this.fade.callback === 'function' && this.fade.callback();
|
||||
if (typeof this.fade.callback === 'function') this.fade.callback();
|
||||
|
||||
// Clear fade
|
||||
this.fade = undefined;
|
||||
@ -382,7 +378,7 @@ export class VolumeFader {
|
||||
input = Math.log10(input);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -191,7 +191,7 @@ export default createPlugin<
|
||||
let waitForTransition: Promise<unknown>;
|
||||
|
||||
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) =>
|
||||
new URLSearchParams(url.split('?')?.at(-1)).get('v');
|
||||
|
||||
@ -202,9 +202,9 @@ export const backend = createBackend<
|
||||
}
|
||||
} else if (!config.hideDurationLeft) {
|
||||
// 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.endTimestamp = songStartTime + (songInfo.songDuration * 1000);
|
||||
activityInfo.endTimestamp = songStartTime + songInfo.songDuration * 1000;
|
||||
}
|
||||
|
||||
info.rpc.user?.setActivity(activityInfo).catch(console.error);
|
||||
|
||||
@ -183,12 +183,18 @@ function downloadSongOnFinishSetup({
|
||||
config.downloadOnFinish.mode === 'seconds' &&
|
||||
duration - time <= config.downloadOnFinish.seconds
|
||||
) {
|
||||
downloadSong(currentUrl, config.downloadOnFinish.folder ?? config.downloadFolder);
|
||||
downloadSong(
|
||||
currentUrl,
|
||||
config.downloadOnFinish.folder ?? config.downloadFolder,
|
||||
);
|
||||
} else if (
|
||||
config.downloadOnFinish.mode === 'percent' &&
|
||||
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,
|
||||
);
|
||||
increasePlaylistProgress(0.15 + (ratio * 0.85));
|
||||
increasePlaylistProgress(0.15 + ratio * 0.85);
|
||||
});
|
||||
|
||||
const safeVideoNameWithExtension = `${safeVideoName}.${extension}`;
|
||||
@ -566,7 +572,13 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
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(
|
||||
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 currentProgress = (counter - 1) / (items.length ?? 1);
|
||||
const newProgress = currentProgress + (progressStep * itemPercentage);
|
||||
const newProgress = currentProgress + progressStep * itemPercentage;
|
||||
win.setProgressBar(newProgress);
|
||||
};
|
||||
|
||||
|
||||
@ -35,7 +35,10 @@ export const onMenu = async ({
|
||||
click(item) {
|
||||
setConfig({
|
||||
downloadOnFinish: {
|
||||
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!,
|
||||
...deepmerge(
|
||||
defaultConfig.downloadOnFinish,
|
||||
config.downloadOnFinish,
|
||||
)!,
|
||||
enabled: item.checked,
|
||||
},
|
||||
});
|
||||
@ -49,14 +52,19 @@ export const onMenu = async ({
|
||||
click() {
|
||||
const result = dialog.showOpenDialogSync({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: getFolder(config.downloadOnFinish?.folder ?? config.downloadFolder),
|
||||
defaultPath: getFolder(
|
||||
config.downloadOnFinish?.folder ?? config.downloadFolder,
|
||||
),
|
||||
});
|
||||
if (result) {
|
||||
setConfig({
|
||||
downloadOnFinish: {
|
||||
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!,
|
||||
...deepmerge(
|
||||
defaultConfig.downloadOnFinish,
|
||||
config.downloadOnFinish,
|
||||
)!,
|
||||
folder: result[0],
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -76,7 +84,10 @@ export const onMenu = async ({
|
||||
click() {
|
||||
setConfig({
|
||||
downloadOnFinish: {
|
||||
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!,
|
||||
...deepmerge(
|
||||
defaultConfig.downloadOnFinish,
|
||||
config.downloadOnFinish,
|
||||
)!,
|
||||
mode: 'seconds',
|
||||
},
|
||||
});
|
||||
@ -91,7 +102,10 @@ export const onMenu = async ({
|
||||
click() {
|
||||
setConfig({
|
||||
downloadOnFinish: {
|
||||
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!,
|
||||
...deepmerge(
|
||||
defaultConfig.downloadOnFinish,
|
||||
config.downloadOnFinish,
|
||||
)!,
|
||||
mode: 'percent',
|
||||
},
|
||||
});
|
||||
@ -120,7 +134,9 @@ export const onMenu = async ({
|
||||
min: '0',
|
||||
step: '1',
|
||||
},
|
||||
value: config.downloadOnFinish?.seconds ?? defaultConfig.downloadOnFinish!.seconds,
|
||||
value:
|
||||
config.downloadOnFinish?.seconds ??
|
||||
defaultConfig.downloadOnFinish!.seconds,
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
@ -133,7 +149,9 @@ export const onMenu = async ({
|
||||
max: '100',
|
||||
step: '1',
|
||||
},
|
||||
value: config.downloadOnFinish?.percent ?? defaultConfig.downloadOnFinish!.percent,
|
||||
value:
|
||||
config.downloadOnFinish?.percent ??
|
||||
defaultConfig.downloadOnFinish!.percent,
|
||||
},
|
||||
],
|
||||
...promptOptions(),
|
||||
@ -147,7 +165,10 @@ export const onMenu = async ({
|
||||
|
||||
setConfig({
|
||||
downloadOnFinish: {
|
||||
...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!,
|
||||
...deepmerge(
|
||||
defaultConfig.downloadOnFinish,
|
||||
config.downloadOnFinish,
|
||||
)!,
|
||||
seconds: Number(res[0]),
|
||||
percent: Number(res[1]),
|
||||
},
|
||||
|
||||
@ -39,7 +39,9 @@ const menuObserver = new MutationObserver(() => {
|
||||
if (!menuUrl?.includes('watch?')) {
|
||||
menuUrl = undefined;
|
||||
// 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/')) {
|
||||
menuUrl = it.getAttribute('href')!;
|
||||
break;
|
||||
@ -72,7 +74,9 @@ export const onRendererLoad = ({
|
||||
?.getAttribute('href');
|
||||
|
||||
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/')) {
|
||||
videoUrl = it.getAttribute('href');
|
||||
break;
|
||||
@ -86,7 +90,8 @@ export const onRendererLoad = ({
|
||||
}
|
||||
|
||||
if (videoUrl.startsWith('podcast/')) {
|
||||
videoUrl = defaultConfig.url + '/watch?' + videoUrl.replace('podcast/', 'v=');
|
||||
videoUrl =
|
||||
defaultConfig.url + '/watch?' + videoUrl.replace('podcast/', 'v=');
|
||||
}
|
||||
|
||||
if (videoUrl.includes('?playlist=')) {
|
||||
|
||||
@ -4,24 +4,12 @@ export interface InAppMenuConfig {
|
||||
}
|
||||
export const defaultInAppMenuConfig: InAppMenuConfig = {
|
||||
enabled:
|
||||
(
|
||||
(
|
||||
typeof window !== 'undefined' &&
|
||||
!window.navigator?.userAgent?.toLowerCase().includes('mac')
|
||||
) ||
|
||||
(
|
||||
typeof global !== 'undefined' &&
|
||||
global.process?.platform !== 'darwin'
|
||||
)
|
||||
) && (
|
||||
(
|
||||
typeof window !== 'undefined' &&
|
||||
!window.navigator?.userAgent?.toLowerCase().includes('linux')
|
||||
) ||
|
||||
(
|
||||
typeof global !== 'undefined' &&
|
||||
global.process?.platform !== 'linux'
|
||||
)
|
||||
),
|
||||
((typeof window !== 'undefined' &&
|
||||
!window.navigator?.userAgent?.toLowerCase().includes('mac')) ||
|
||||
(typeof global !== 'undefined' &&
|
||||
global.process?.platform !== 'darwin')) &&
|
||||
((typeof window !== 'undefined' &&
|
||||
!window.navigator?.userAgent?.toLowerCase().includes('linux')) ||
|
||||
(typeof global !== 'undefined' && global.process?.platform !== 'linux')),
|
||||
hideDOMWindowControls: false,
|
||||
};
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
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 { InAppMenuConfig } from './constants';
|
||||
@ -50,11 +57,13 @@ export const onMainLoad = ({
|
||||
ipcMain.handle('ytmd:menu-event', (event, commandId: number) => {
|
||||
const target = getMenuItemById(commandId);
|
||||
if (target)
|
||||
target.click(
|
||||
undefined,
|
||||
BrowserWindow.fromWebContents(event.sender),
|
||||
event.sender,
|
||||
);
|
||||
(
|
||||
target.click as (
|
||||
args0: unknown,
|
||||
args1: BrowserWindow | null,
|
||||
args3: WebContents,
|
||||
) => void
|
||||
)(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
|
||||
});
|
||||
|
||||
handle('get-menu-by-id', (commandId: number) => {
|
||||
|
||||
@ -16,8 +16,9 @@ const isMacOS = navigator.userAgent.includes('Macintosh');
|
||||
const isNotWindowsOrMacOS =
|
||||
!navigator.userAgent.includes('Windows') && !isMacOS;
|
||||
|
||||
|
||||
const [config, setConfig] = createSignal<InAppMenuConfig>(defaultInAppMenuConfig);
|
||||
const [config, setConfig] = createSignal<InAppMenuConfig>(
|
||||
defaultInAppMenuConfig,
|
||||
);
|
||||
export const onRendererLoad = async ({
|
||||
getConfig,
|
||||
ipc,
|
||||
@ -29,14 +30,19 @@ export const onRendererLoad = async ({
|
||||
stylesheet.replaceSync(scrollStyle);
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet];
|
||||
|
||||
render(() => (
|
||||
<TitleBar
|
||||
ipc={ipc}
|
||||
isMacOS={isMacOS}
|
||||
enableController={isNotWindowsOrMacOS && !config().hideDOMWindowControls}
|
||||
initialCollapsed={window.mainConfig.get('options.hideMenu')}
|
||||
/>
|
||||
), document.body);
|
||||
render(
|
||||
() => (
|
||||
<TitleBar
|
||||
ipc={ipc}
|
||||
isMacOS={isMacOS}
|
||||
enableController={
|
||||
isNotWindowsOrMacOS && !config().hideDOMWindowControls
|
||||
}
|
||||
initialCollapsed={window.mainConfig.get('options.hideMenu')}
|
||||
/>
|
||||
),
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export const onPlayerApiReady = () => {
|
||||
|
||||
@ -3,36 +3,38 @@ import { css } from 'solid-styled-components';
|
||||
|
||||
import { cacheNoArgs } from '@/providers/decorators';
|
||||
|
||||
const iconButton = cacheNoArgs(() => css`
|
||||
-webkit-app-region: none;
|
||||
const iconButton = cacheNoArgs(
|
||||
() => css`
|
||||
-webkit-app-region: none;
|
||||
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
color: white;
|
||||
color: white;
|
||||
|
||||
outline: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
scale: 0.9;
|
||||
}
|
||||
`);
|
||||
&:active {
|
||||
scale: 0.9;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
type CollapseIconButtonProps = JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
export const IconButton = (props: CollapseIconButtonProps) => {
|
||||
|
||||
@ -3,31 +3,33 @@ import { css } from 'solid-styled-components';
|
||||
|
||||
import { cacheNoArgs } from '@/providers/decorators';
|
||||
|
||||
const menuStyle = cacheNoArgs(() => css`
|
||||
-webkit-app-region: none;
|
||||
const menuStyle = cacheNoArgs(
|
||||
() => css`
|
||||
-webkit-app-region: none;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
&:active {
|
||||
scale: 0.9;
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
&:active {
|
||||
scale: 0.9;
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`);
|
||||
&[data-selected='true'] {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
export type MenuButtonProps = JSX.HTMLAttributes<HTMLLIElement> & {
|
||||
text?: string;
|
||||
|
||||
@ -2,39 +2,48 @@ import { createSignal, JSX, Show, splitProps } from 'solid-js';
|
||||
import { mergeProps, Portal } from 'solid-js/web';
|
||||
import { css } from 'solid-styled-components';
|
||||
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 { cacheNoArgs } from '@/providers/decorators';
|
||||
|
||||
const panelStyle = cacheNoArgs(() => css`
|
||||
position: fixed;
|
||||
top: var(--offset-y, 0);
|
||||
left: var(--offset-x, 0);
|
||||
const panelStyle = cacheNoArgs(
|
||||
() => css`
|
||||
position: fixed;
|
||||
top: var(--offset-y, 0);
|
||||
left: var(--offset-x, 0);
|
||||
|
||||
max-width: var(--max-width, 100%);
|
||||
max-height: var(--max-height, 100%);
|
||||
max-width: var(--max-width, 100%);
|
||||
max-height: var(--max-height, 100%);
|
||||
|
||||
z-index: 10000;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
z-index: 10000;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--titlebar-background-color, #030303) 50%,
|
||||
rgba(0, 0, 0, 0.1)
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--titlebar-background-color, #030303) 50%,
|
||||
rgba(0, 0, 0, 0.1)
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.05),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
|
||||
transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
|
||||
`);
|
||||
transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
|
||||
`,
|
||||
);
|
||||
|
||||
const animationStyle = cacheNoArgs(() => ({
|
||||
enter: css`
|
||||
@ -42,19 +51,23 @@ const animationStyle = cacheNoArgs(() => ({
|
||||
transform: scale(0.9);
|
||||
`,
|
||||
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`
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
`,
|
||||
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 =
|
||||
'top'
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
@ -92,9 +105,15 @@ export const Panel = (props: PanelProps) => {
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ elements, availableWidth, availableHeight }) {
|
||||
elements.floating.style.setProperty('--max-width', `${Math.max(200, availableWidth)}px`);
|
||||
elements.floating.style.setProperty('--max-height', `${Math.max(200, availableHeight)}px`);
|
||||
}
|
||||
elements.floating.style.setProperty(
|
||||
'--max-width',
|
||||
`${Math.max(200, availableWidth)}px`,
|
||||
);
|
||||
elements.floating.style.setProperty(
|
||||
'--max-height',
|
||||
`${Math.max(200, availableHeight)}px`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
flip({ fallbackStrategy: 'initialPlacement' }),
|
||||
],
|
||||
@ -103,7 +122,10 @@ export const Panel = (props: PanelProps) => {
|
||||
const originX = () => {
|
||||
if (position.placement.includes('left')) return '100%';
|
||||
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('end')) return '100%';
|
||||
}
|
||||
@ -113,7 +135,10 @@ export const Panel = (props: PanelProps) => {
|
||||
const originY = () => {
|
||||
if (position.placement.includes('top')) return '100%';
|
||||
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('end')) return '100%';
|
||||
}
|
||||
|
||||
@ -10,100 +10,111 @@ import { autoUpdate, offset, size } from '@floating-ui/dom';
|
||||
import { Panel } from './Panel';
|
||||
import { cacheNoArgs } from '@/providers/decorators';
|
||||
|
||||
const itemStyle = cacheNoArgs(() => css`
|
||||
position: relative;
|
||||
const itemStyle = cacheNoArgs(
|
||||
() => css`
|
||||
position: relative;
|
||||
|
||||
-webkit-app-region: none;
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
-webkit-app-region: none;
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto minmax(32px, auto);
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto minmax(32px, auto);
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
& * {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`);
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
|
||||
const itemIconStyle = cacheNoArgs(() => css`
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
color: white;
|
||||
`);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
const itemLabelStyle = cacheNoArgs(() => css`
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
`);
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
const itemChipStyle = cacheNoArgs(() => css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
&:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
margin-left: 8px;
|
||||
&[data-selected='true'] {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: #f1f1f1;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
`);
|
||||
& * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const toolTipStyle = cacheNoArgs(() => css`
|
||||
min-width: 32px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
const itemIconStyle = cacheNoArgs(
|
||||
() => css`
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
color: white;
|
||||
`,
|
||||
);
|
||||
|
||||
padding: 4px;
|
||||
const itemLabelStyle = cacheNoArgs(
|
||||
() => css`
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
`,
|
||||
);
|
||||
|
||||
max-width: calc(var(--max-width, 100%) - 8px);
|
||||
max-height: calc(var(--max-height, 100%) - 8px);
|
||||
const itemChipStyle = cacheNoArgs(
|
||||
() => css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 4px;
|
||||
background-color: rgba(25, 25, 25, 0.8);
|
||||
color: #f1f1f1;
|
||||
font-size: 10px;
|
||||
`);
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
margin-left: 8px;
|
||||
|
||||
const popupStyle = cacheNoArgs(() => css`
|
||||
position: fixed;
|
||||
top: var(--offset-y, 0);
|
||||
left: var(--offset-x, 0);
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: #f1f1f1;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
`,
|
||||
);
|
||||
|
||||
max-width: var(--max-width, 100%);
|
||||
max-height: var(--max-height, 100%);
|
||||
const toolTipStyle = cacheNoArgs(
|
||||
() => css`
|
||||
min-width: 32px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
z-index: 100000000;
|
||||
pointer-events: none;
|
||||
padding: 4px;
|
||||
|
||||
`);
|
||||
max-width: calc(var(--max-width, 100%) - 8px);
|
||||
max-height: calc(var(--max-height, 100%) - 8px);
|
||||
|
||||
border-radius: 4px;
|
||||
background-color: rgba(25, 25, 25, 0.8);
|
||||
color: #f1f1f1;
|
||||
font-size: 10px;
|
||||
`,
|
||||
);
|
||||
|
||||
const popupStyle = cacheNoArgs(
|
||||
() => css`
|
||||
position: fixed;
|
||||
top: var(--offset-y, 0);
|
||||
left: var(--offset-x, 0);
|
||||
|
||||
max-width: var(--max-width, 100%);
|
||||
max-height: var(--max-height, 100%);
|
||||
|
||||
z-index: 100000000;
|
||||
pointer-events: none;
|
||||
`,
|
||||
);
|
||||
|
||||
const animationStyle = cacheNoArgs(() => ({
|
||||
enter: css`
|
||||
@ -111,14 +122,18 @@ const animationStyle = cacheNoArgs(() => ({
|
||||
transform: scale(0.9);
|
||||
`,
|
||||
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`
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
`,
|
||||
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;
|
||||
onChange?: (checked: boolean) => void;
|
||||
};
|
||||
export type PanelItemProps = NormalPanelItemProps | SubmenuItemProps | RadioPanelItemProps | CheckboxPanelItemProps;
|
||||
export type PanelItemProps =
|
||||
| NormalPanelItemProps
|
||||
| SubmenuItemProps
|
||||
| RadioPanelItemProps
|
||||
| CheckboxPanelItemProps;
|
||||
export const PanelItem = (props: PanelItemProps) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [toolTipOpen, setToolTipOpen] = createSignal(false);
|
||||
@ -176,17 +195,24 @@ export const PanelItem = (props: PanelItemProps) => {
|
||||
offset({ mainAxis: 8 }),
|
||||
size({
|
||||
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) => {
|
||||
setToolTipOpen(true);
|
||||
event.target?.addEventListener('mouseleave', () => {
|
||||
setToolTipOpen(false);
|
||||
}, { once: true });
|
||||
event.target?.addEventListener(
|
||||
'mouseleave',
|
||||
() => {
|
||||
setToolTipOpen(false);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
if (props.type === 'submenu') {
|
||||
const timer = setTimeout(() => {
|
||||
@ -200,36 +226,54 @@ export const PanelItem = (props: PanelItemProps) => {
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
|
||||
event.target?.addEventListener('mouseleave', () => {
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
const parents = getParents(document.elementFromPoint(mouseX, mouseY));
|
||||
event.target?.addEventListener(
|
||||
'mouseleave',
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
const parents = getParents(
|
||||
document.elementFromPoint(mouseX, mouseY),
|
||||
);
|
||||
|
||||
if (!parents.includes(child())) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
const onOtherHover = (event: MouseEvent) => {
|
||||
const parents = getParents(event.target as HTMLElement);
|
||||
const closestLevel = parents.find((it) => it?.dataset?.level)?.dataset.level ?? '';
|
||||
const path = event.composedPath();
|
||||
if (!parents.includes(child())) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
const onOtherHover = (event: MouseEvent) => {
|
||||
const parents = getParents(event.target as HTMLElement);
|
||||
const closestLevel =
|
||||
parents.find((it) => it?.dataset?.level)?.dataset.level ??
|
||||
'';
|
||||
const path = event.composedPath();
|
||||
|
||||
const isOtherItem = path.some((it) => it instanceof HTMLElement && it.classList.contains(itemStyle()));
|
||||
const isChild = closestLevel.startsWith(props.level.join('/'));
|
||||
const isOtherItem = path.some(
|
||||
(it) =>
|
||||
it instanceof HTMLElement &&
|
||||
it.classList.contains(itemStyle()),
|
||||
);
|
||||
const isChild = closestLevel.startsWith(
|
||||
props.level.join('/'),
|
||||
);
|
||||
|
||||
if (isOtherItem && !isChild) {
|
||||
setOpen(false);
|
||||
document.removeEventListener('mousemove', onOtherHover);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousemove', onOtherHover);
|
||||
}
|
||||
}, 225);
|
||||
}, { once: true });
|
||||
if (isOtherItem && !isChild) {
|
||||
setOpen(false);
|
||||
document.removeEventListener('mousemove', onOtherHover);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousemove', onOtherHover);
|
||||
}
|
||||
}, 225);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}, 225);
|
||||
|
||||
event.target?.addEventListener('mouseleave', () => {
|
||||
clearTimeout(timer);
|
||||
}, { once: true });
|
||||
event.target?.addEventListener(
|
||||
'mouseleave',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -244,7 +288,6 @@ export const PanelItem = (props: PanelItemProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setAnchor}
|
||||
@ -253,45 +296,66 @@ export const PanelItem = (props: PanelItemProps) => {
|
||||
onClick={handleClick}
|
||||
data-selected={open()}
|
||||
>
|
||||
<Switch fallback={<div class={itemIconStyle()}/>}>
|
||||
<Switch fallback={<div class={itemIconStyle()} />}>
|
||||
<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"
|
||||
stroke="currentColor" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M5 12l5 5l10 -10"/>
|
||||
<svg
|
||||
class={itemIconStyle()}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M5 12l5 5l10 -10" />
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={props.type === 'radio' && props.checked}>
|
||||
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" 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
|
||||
class={itemIconStyle()}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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>
|
||||
</Match>
|
||||
<Match when={props.type === 'radio' && !props.checked}>
|
||||
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" 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
|
||||
class={itemIconStyle()}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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>
|
||||
</Match>
|
||||
</Switch>
|
||||
<span class={itemLabelStyle()}>
|
||||
{props.name}
|
||||
</span>
|
||||
<Show when={props.chip} fallback={<div/>}>
|
||||
<span class={itemChipStyle()}>
|
||||
{props.chip}
|
||||
</span>
|
||||
<span class={itemLabelStyle()}>{props.name}</span>
|
||||
<Show when={props.chip} fallback={<div />}>
|
||||
<span class={itemChipStyle()}>{props.chip}</span>
|
||||
</Show>
|
||||
<Show when={props.type === 'submenu'}>
|
||||
<svg class={itemIconStyle()} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<polyline points="9 6 15 12 9 18"/>
|
||||
<svg
|
||||
class={itemIconStyle()}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
<Panel
|
||||
ref={setChild}
|
||||
@ -322,9 +386,7 @@ export const PanelItem = (props: PanelItemProps) => {
|
||||
exitActiveClass={animationStyle().exitActive}
|
||||
>
|
||||
<Show when={toolTipOpen()}>
|
||||
<div class={toolTipStyle()}>
|
||||
{props.toolTip}
|
||||
</div>
|
||||
<div class={toolTipStyle()}>{props.toolTip}</div>
|
||||
</Show>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
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 { TransitionGroup } from 'solid-transition-group';
|
||||
|
||||
@ -14,49 +24,55 @@ import { cacheNoArgs } from '@/providers/decorators';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { InAppMenuConfig } from '../constants';
|
||||
|
||||
const titleStyle = cacheNoArgs(() => css`
|
||||
-webkit-app-region: drag;
|
||||
box-sizing: border-box;
|
||||
const titleStyle = cacheNoArgs(
|
||||
() => css`
|
||||
-webkit-app-region: drag;
|
||||
box-sizing: border-box;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 10000000;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 10000000;
|
||||
|
||||
width: 100%;
|
||||
height: var(--menu-bar-height, 32px);
|
||||
width: 100%;
|
||||
height: var(--menu-bar-height, 32px);
|
||||
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
color: #f1f1f1;
|
||||
font-size: 12px;
|
||||
padding: 4px 4px 4px var(--offset-left, 4px);
|
||||
background-color: var(--titlebar-background-color, #030303);
|
||||
user-select: none;
|
||||
color: #f1f1f1;
|
||||
font-size: 12px;
|
||||
padding: 4px 4px 4px var(--offset-left, 4px);
|
||||
background-color: var(--titlebar-background-color, #030303);
|
||||
user-select: none;
|
||||
|
||||
transition: opacity 200ms ease 0s,
|
||||
transform 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s,
|
||||
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
|
||||
transition:
|
||||
opacity 200ms ease 0s,
|
||||
transform 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"] {
|
||||
padding: 4px 4px 4px 74px;
|
||||
}
|
||||
&[data-macos='true'] {
|
||||
padding: 4px 4px 4px 74px;
|
||||
}
|
||||
|
||||
ytmusic-app:has(ytmusic-player[player-ui-state=FULLSCREEN]) ~ &:not([data-show="true"]) {
|
||||
transform: translateY(calc(-1 * var(--menu-bar-height, 32px)));
|
||||
}
|
||||
`);
|
||||
ytmusic-app:has(ytmusic-player[player-ui-state='FULLSCREEN'])
|
||||
~ &:not([data-show='true']) {
|
||||
transform: translateY(calc(-1 * var(--menu-bar-height, 32px)));
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const separatorStyle = cacheNoArgs(() => css`
|
||||
min-height: 1px;
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
const separatorStyle = cacheNoArgs(
|
||||
() => css`
|
||||
min-height: 1px;
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
`);
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
`,
|
||||
);
|
||||
|
||||
const animationStyle = cacheNoArgs(() => ({
|
||||
enter: css`
|
||||
@ -64,14 +80,18 @@ const animationStyle = cacheNoArgs(() => ({
|
||||
transform: translateX(-50%) scale(0.8);
|
||||
`,
|
||||
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`
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.8);
|
||||
`,
|
||||
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`
|
||||
transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
@ -89,7 +109,7 @@ export type PanelRendererProps = {
|
||||
items: Electron.Menu['items'];
|
||||
level?: number[];
|
||||
onClick?: (commandId: number, radioGroup?: MenuItem[]) => void;
|
||||
}
|
||||
};
|
||||
const PanelRenderer = (props: PanelRendererProps) => {
|
||||
const radioGroup = () => props.items.filter((it) => it.type === 'radio');
|
||||
|
||||
@ -114,12 +134,12 @@ const PanelRenderer = (props: PanelRendererProps) => {
|
||||
name={subItem().label}
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
level={[...props.level ?? [], subItem().commandId]}
|
||||
level={[...(props.level ?? []), subItem().commandId]}
|
||||
commandId={subItem().commandId}
|
||||
>
|
||||
<PanelRenderer
|
||||
items={subItem().submenu?.items ?? []}
|
||||
level={[...props.level ?? [], subItem().commandId]}
|
||||
level={[...(props.level ?? []), subItem().commandId]}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
</PanelItem>
|
||||
@ -143,11 +163,13 @@ const PanelRenderer = (props: PanelRendererProps) => {
|
||||
chip={subItem().sublabel}
|
||||
toolTip={subItem().toolTip}
|
||||
commandId={subItem().commandId}
|
||||
onChange={() => props.onClick?.(subItem().commandId, radioGroup())}
|
||||
onChange={() =>
|
||||
props.onClick?.(subItem().commandId, radioGroup())
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={subItem().type === 'separator'}>
|
||||
<hr class={separatorStyle()}/>
|
||||
<hr class={separatorStyle()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
@ -169,8 +191,13 @@ export const TitleBar = (props: TitleBarProps) => {
|
||||
const [menu, setMenu] = createSignal<Menu | null>(null);
|
||||
const [mouseY, setMouseY] = createSignal(0);
|
||||
|
||||
const [data, { refetch }] = createResource(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 [data, { refetch }] = createResource(
|
||||
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 () => {
|
||||
if (isMaximized()) {
|
||||
@ -194,10 +221,12 @@ export const TitleBar = (props: TitleBarProps) => {
|
||||
)) as MenuItem | null;
|
||||
|
||||
const newMenu = structuredClone(originalMenu);
|
||||
const stack = [...newMenu?.items ?? []];
|
||||
const stack = [...(newMenu?.items ?? [])];
|
||||
let now: MenuItem | undefined = stack.pop();
|
||||
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 (menuItem) now?.submenu?.items?.splice(index, 1, menuItem);
|
||||
@ -213,13 +242,16 @@ export const TitleBar = (props: TitleBarProps) => {
|
||||
return newMenu;
|
||||
};
|
||||
|
||||
const handleItemClick = async (commandId: number, radioGroup?: MenuItem[]) => {
|
||||
const handleItemClick = async (
|
||||
commandId: number,
|
||||
radioGroup?: MenuItem[],
|
||||
) => {
|
||||
const menuData = menu();
|
||||
if (!menuData) return;
|
||||
|
||||
if (Array.isArray(radioGroup)) {
|
||||
let newMenu = menuData;
|
||||
for await (const item of radioGroup) {
|
||||
for (const item of radioGroup) {
|
||||
newMenu = await refreshMenuItem(newMenu, item.commandId);
|
||||
}
|
||||
|
||||
@ -272,18 +304,15 @@ export const TitleBar = (props: TitleBarProps) => {
|
||||
window.addEventListener('mousemove', listener);
|
||||
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
||||
ytmusicAppLayout?.addEventListener('scroll', () => {
|
||||
const scrollValue = ytmusicAppLayout.scrollTop;
|
||||
if (scrollValue > 20){
|
||||
ytmusicAppLayout.classList.add('content-scrolled');
|
||||
}
|
||||
else{
|
||||
ytmusicAppLayout.classList.remove('content-scrolled');
|
||||
}
|
||||
const scrollValue = ytmusicAppLayout.scrollTop;
|
||||
if (scrollValue > 20) {
|
||||
ytmusicAppLayout.classList.add('content-scrolled');
|
||||
} else {
|
||||
ytmusicAppLayout.classList.remove('content-scrolled');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (!menu() && data()) {
|
||||
setMenu(data() ?? null);
|
||||
@ -295,7 +324,12 @@ export const TitleBar = (props: TitleBarProps) => {
|
||||
});
|
||||
|
||||
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
|
||||
onClick={() => setCollapsed(!collapsed())}
|
||||
style={{
|
||||
@ -310,15 +344,34 @@ export const TitleBar = (props: TitleBarProps) => {
|
||||
</svg>
|
||||
</IconButton>
|
||||
<TransitionGroup
|
||||
enterClass={ignoreTransition() ? animationStyle().fakeTarget : animationStyle().enter}
|
||||
enterActiveClass={ignoreTransition() ? animationStyle().fake : animationStyle().enterActive}
|
||||
exitToClass={ignoreTransition() ? animationStyle().fakeTarget : animationStyle().exitTo}
|
||||
exitActiveClass={ignoreTransition() ? animationStyle().fake : animationStyle().exitActive}
|
||||
enterClass={
|
||||
ignoreTransition()
|
||||
? animationStyle().fakeTarget
|
||||
: animationStyle().enter
|
||||
}
|
||||
enterActiveClass={
|
||||
ignoreTransition()
|
||||
? animationStyle().fake
|
||||
: animationStyle().enterActive
|
||||
}
|
||||
exitToClass={
|
||||
ignoreTransition()
|
||||
? animationStyle().fakeTarget
|
||||
: animationStyle().exitTo
|
||||
}
|
||||
exitActiveClass={
|
||||
ignoreTransition()
|
||||
? animationStyle().fake
|
||||
: animationStyle().exitActive
|
||||
}
|
||||
onBeforeEnter={(element) => {
|
||||
if (ignoreTransition()) return;
|
||||
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) => {
|
||||
(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 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()}>
|
||||
<Index each={menu()?.items}>
|
||||
{(item, index) => {
|
||||
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
||||
const [anchor, setAnchor] = createSignal<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
if (openTarget() === anchor()) {
|
||||
@ -372,7 +430,7 @@ export const TitleBar = (props: TitleBarProps) => {
|
||||
</Show>
|
||||
</TransitionGroup>
|
||||
<Show when={props.enableController}>
|
||||
<div style={{ flex: 1 }}/>
|
||||
<div style={{ flex: 1 }} />
|
||||
<WindowController
|
||||
isMaximize={isMaximized()}
|
||||
onToggleMaximize={handleToggleMaximize}
|
||||
@ -383,4 +441,3 @@ export const TitleBar = (props: TitleBarProps) => {
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -4,19 +4,21 @@ import { Show } from 'solid-js';
|
||||
import { IconButton } from './IconButton';
|
||||
import { cacheNoArgs } from '@/providers/decorators';
|
||||
|
||||
const containerStyle = cacheNoArgs(() => css`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
const containerStyle = cacheNoArgs(
|
||||
() => css`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
& > *:last-of-type {
|
||||
border-top-right-radius: 4px;
|
||||
& > *:last-of-type {
|
||||
border-top-right-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 0, 0, 0.5);
|
||||
&:hover {
|
||||
background: rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
`,
|
||||
);
|
||||
|
||||
export type WindowControllerProps = {
|
||||
isMaximize?: boolean;
|
||||
@ -24,20 +26,35 @@ export type WindowControllerProps = {
|
||||
onToggleMaximize?: () => void;
|
||||
onMinimize?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
};
|
||||
export const WindowController = (props: WindowControllerProps) => {
|
||||
return (
|
||||
<div class={containerStyle()}>
|
||||
<IconButton onClick={props.onMinimize}>
|
||||
<svg 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
|
||||
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>
|
||||
</IconButton>
|
||||
<IconButton onClick={props.onToggleMaximize}>
|
||||
<Show
|
||||
when={props.isMaximize}
|
||||
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
|
||||
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"
|
||||
@ -45,7 +62,13 @@ export const WindowController = (props: WindowControllerProps) => {
|
||||
</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
|
||||
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"
|
||||
@ -54,7 +77,13 @@ export const WindowController = (props: WindowControllerProps) => {
|
||||
</Show>
|
||||
</IconButton>
|
||||
<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
|
||||
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"
|
||||
|
||||
@ -77,7 +77,7 @@ export const onRendererLoad = ({
|
||||
applyLyricsTabState();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const applyLyricsTabState = () => {
|
||||
if (lyrics) {
|
||||
tabs.lyrics.removeAttribute('disabled');
|
||||
|
||||
@ -3,13 +3,15 @@ import { DataConnection, Peer } from 'peerjs';
|
||||
import type { Permission, Profile, VideoData } from './types';
|
||||
|
||||
export type ConnectionEventMap = {
|
||||
ADD_SONGS: { videoList: VideoData[], index?: number };
|
||||
ADD_SONGS: { videoList: VideoData[]; index?: number };
|
||||
REMOVE_SONG: { index: number };
|
||||
MOVE_SONG: { fromIndex: number; toIndex: number };
|
||||
IDENTIFY: { profile: Profile } | undefined;
|
||||
SYNC_PROFILE: { profiles: Record<string, Profile> } | 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;
|
||||
};
|
||||
export type ConnectionEventUnion = {
|
||||
@ -24,9 +26,12 @@ type PromiseUtil<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (id: T) => 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 class Connection {
|
||||
private peer: Peer;
|
||||
@ -95,9 +100,12 @@ export class Connection {
|
||||
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(
|
||||
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));
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import itemHTML from './templates/item.html?raw';
|
||||
import popupHTML from './templates/popup.html?raw';
|
||||
|
||||
type Placement =
|
||||
'top'
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'right'
|
||||
| 'left'
|
||||
@ -15,32 +15,40 @@ type Placement =
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
type PopupItem = (ItemRendererProps & { type: 'item'; })
|
||||
| { type: 'divider'; }
|
||||
| { type: 'custom'; element: HTMLElement; };
|
||||
type PopupItem =
|
||||
| (ItemRendererProps & { type: 'item' })
|
||||
| { type: 'divider' }
|
||||
| { type: 'custom'; element: HTMLElement };
|
||||
|
||||
type PopupProps = {
|
||||
data: PopupItem[];
|
||||
anchorAt?: Placement;
|
||||
popupAt?: Placement;
|
||||
}
|
||||
};
|
||||
export const Popup = (props: PopupProps) => {
|
||||
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
|
||||
.map((props) => {
|
||||
if (props.type === 'item') return {
|
||||
type: 'item' as const,
|
||||
...ItemRenderer(props),
|
||||
};
|
||||
if (props.type === 'divider') return {
|
||||
type: 'divider' as const,
|
||||
element: ElementFromHtml('<div class="music-together-divider horizontal"></div>'),
|
||||
};
|
||||
if (props.type === 'custom') return {
|
||||
type: 'custom' as const,
|
||||
element: props.element,
|
||||
};
|
||||
if (props.type === 'item')
|
||||
return {
|
||||
type: 'item' as const,
|
||||
...ItemRenderer(props),
|
||||
};
|
||||
if (props.type === 'divider')
|
||||
return {
|
||||
type: 'divider' as const,
|
||||
element: ElementFromHtml(
|
||||
'<div class="music-together-divider horizontal"></div>',
|
||||
),
|
||||
};
|
||||
if (props.type === 'custom')
|
||||
return {
|
||||
type: 'custom' as const,
|
||||
element: props.element,
|
||||
};
|
||||
|
||||
return null;
|
||||
})
|
||||
@ -80,7 +88,9 @@ export const Popup = (props: PopupProps) => {
|
||||
|
||||
setTimeout(() => {
|
||||
const onClose = (event: MouseEvent) => {
|
||||
const isPopupClick = event.composedPath().some((element) => element === popup);
|
||||
const isPopupClick = event
|
||||
.composedPath()
|
||||
.some((element) => element === popup);
|
||||
if (!isPopupClick) {
|
||||
this.dismiss();
|
||||
document.removeEventListener('click', onClose);
|
||||
@ -101,7 +111,7 @@ export const Popup = (props: PopupProps) => {
|
||||
dismiss() {
|
||||
popup.style.setProperty('opacity', '0');
|
||||
popup.style.setProperty('pointer-events', 'none');
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -133,6 +143,6 @@ export const ItemRenderer = (props: ItemRendererProps) => {
|
||||
setText(text: string) {
|
||||
textContainer.replaceChildren(text);
|
||||
},
|
||||
id: props.id
|
||||
id: props.id,
|
||||
};
|
||||
};
|
||||
|
||||
@ -6,7 +6,12 @@ import { t } from '@/i18n';
|
||||
import { createPlugin } from '@/utils';
|
||||
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 { Connection, type ConnectionEventUnion } from './connection';
|
||||
import { createHostPopup } from './ui/host';
|
||||
@ -26,7 +31,7 @@ type RawAccountData = {
|
||||
runs: { text: string }[];
|
||||
};
|
||||
accountPhoto: {
|
||||
thumbnails: { url: string; width: number; height: number; }[];
|
||||
thumbnails: { url: string; width: number; height: number }[];
|
||||
};
|
||||
settingsEndpoint: unknown;
|
||||
manageAccountTitle: unknown;
|
||||
@ -59,7 +64,7 @@ export default createPlugin<
|
||||
stateInterval?: number;
|
||||
updateNext: boolean;
|
||||
ignoreChange: boolean;
|
||||
rollbackInjector?: (() => void);
|
||||
rollbackInjector?: () => void;
|
||||
me?: Omit<Profile, 'id'>;
|
||||
profiles: Record<string, Profile>;
|
||||
permission: Permission;
|
||||
@ -79,16 +84,18 @@ export default createPlugin<
|
||||
restartNeeded: false,
|
||||
addedVersion: '3.2.X',
|
||||
config: {
|
||||
enabled: false
|
||||
enabled: false,
|
||||
},
|
||||
stylesheets: [style],
|
||||
backend({ ipc }) {
|
||||
ipc.handle('music-together:prompt', async (title: string, label: string) => prompt({
|
||||
title,
|
||||
label,
|
||||
type: 'input',
|
||||
...promptOptions()
|
||||
}));
|
||||
ipc.handle('music-together:prompt', async (title: string, label: string) =>
|
||||
prompt({
|
||||
title,
|
||||
label,
|
||||
type: 'input',
|
||||
...promptOptions(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
renderer: {
|
||||
updateNext: false,
|
||||
@ -112,15 +119,19 @@ export default createPlugin<
|
||||
videoChangeListener(event: CustomEvent<VideoDataChanged>) {
|
||||
if (event.detail.name === 'dataloaded' || this.updateNext) {
|
||||
if (this.connection?.mode === 'host') {
|
||||
const videoList: VideoData[] = this.queue?.flatItems.map((it) => ({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.connection!.id
|
||||
} satisfies VideoData)) ?? [];
|
||||
const videoList: VideoData[] =
|
||||
this.queue?.flatItems.map(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.connection!.id,
|
||||
}) satisfies VideoData,
|
||||
) ?? [];
|
||||
|
||||
this.queue?.setVideoList(videoList, false);
|
||||
this.queue?.syncQueueOwner();
|
||||
this.connection.broadcast('SYNC_QUEUE', {
|
||||
videoList
|
||||
videoList,
|
||||
});
|
||||
|
||||
this.updateNext = event.detail.name === 'dataloaded';
|
||||
@ -138,7 +149,7 @@ export default createPlugin<
|
||||
|
||||
this.connection.broadcast('SYNC_PROGRESS', {
|
||||
// progress: this.playerApi?.getCurrentTime(),
|
||||
state: this.playerApi?.getPlayerState()
|
||||
state: this.playerApi?.getPlayerState(),
|
||||
// index: this.queue?.selectedIndex ?? 0,
|
||||
});
|
||||
},
|
||||
@ -150,13 +161,17 @@ export default createPlugin<
|
||||
if (!wait) return false;
|
||||
|
||||
if (!this.me) this.me = getDefaultProfile(this.connection.id);
|
||||
const rawItems = this.queue?.flatItems?.map((it) => ({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.connection!.id
|
||||
} satisfies VideoData)) ?? [];
|
||||
const rawItems =
|
||||
this.queue?.flatItems?.map(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.connection!.id,
|
||||
}) satisfies VideoData,
|
||||
) ?? [];
|
||||
this.queue?.setOwner({
|
||||
id: this.connection.id,
|
||||
...this.me
|
||||
...this.me,
|
||||
});
|
||||
this.queue?.setVideoList(rawItems, false);
|
||||
this.queue?.syncQueueOwner();
|
||||
@ -166,31 +181,41 @@ export default createPlugin<
|
||||
this.profiles = {};
|
||||
this.connection.onConnections((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();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connection.open) {
|
||||
this.api?.toastService?.show(t('plugins.music-together.toast.user-disconnected', {
|
||||
name: this.profiles[connection.peer]?.name
|
||||
}));
|
||||
this.api?.toastService?.show(
|
||||
t('plugins.music-together.toast.user-disconnected', {
|
||||
name: this.profiles[connection.peer]?.name,
|
||||
}),
|
||||
);
|
||||
this.putProfile(connection.peer, undefined);
|
||||
}
|
||||
});
|
||||
this.putProfile(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;
|
||||
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
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);
|
||||
break;
|
||||
}
|
||||
@ -204,27 +229,38 @@ export default createPlugin<
|
||||
case 'MOVE_SONG': {
|
||||
if (conn && this.permission === 'host-only') {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? []
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
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);
|
||||
break;
|
||||
}
|
||||
case 'IDENTIFY': {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_PROFILE': {
|
||||
await this.connection?.broadcast('SYNC_PROFILE', { profiles: this.profiles });
|
||||
await this.connection?.broadcast('SYNC_PROFILE', {
|
||||
profiles: this.profiles,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
@ -237,7 +273,7 @@ export default createPlugin<
|
||||
}
|
||||
case 'SYNC_QUEUE': {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? []
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
@ -251,7 +287,8 @@ export default createPlugin<
|
||||
if (permissionLevel >= 2) {
|
||||
if (typeof event.payload?.progress === 'number') {
|
||||
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 (event.payload?.state === 2) this.playerApi?.pauseVideo();
|
||||
@ -300,25 +337,32 @@ export default createPlugin<
|
||||
|
||||
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;
|
||||
|
||||
const connection = await this.connection.connect(id).catch(() => false);
|
||||
if (!connection) return false;
|
||||
this.connection.onConnections((connection) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
let resolveIgnore: number | null = null;
|
||||
const listener = async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
await this.queue?.addVideos(event.payload.videoList, event.payload.index);
|
||||
await this.queue?.addVideos(
|
||||
event.payload.videoList,
|
||||
event.payload.index,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
@ -326,11 +370,16 @@ export default createPlugin<
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
this.queue?.moveItem(event.payload.fromIndex, event.payload.toIndex);
|
||||
this.queue?.moveItem(
|
||||
event.payload.fromIndex,
|
||||
event.payload.toIndex,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'IDENTIFY': {
|
||||
console.warn('Music Together [Guest]: Received "IDENTIFY" event from guest');
|
||||
console.warn(
|
||||
'Music Together [Guest]: Received "IDENTIFY" event from guest',
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_QUEUE': {
|
||||
@ -341,7 +390,9 @@ export default createPlugin<
|
||||
}
|
||||
case 'SYNC_PROFILE': {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -353,7 +404,8 @@ export default createPlugin<
|
||||
case 'SYNC_PROGRESS': {
|
||||
if (typeof event.payload?.progress === 'number') {
|
||||
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 (event.payload?.state === 2) this.playerApi?.pauseVideo();
|
||||
@ -370,7 +422,9 @@ export default createPlugin<
|
||||
}
|
||||
case 'PERMISSION': {
|
||||
if (!event.payload) {
|
||||
console.warn('Music Together [Guest]: Received "PERMISSION" event without payload');
|
||||
console.warn(
|
||||
'Music Together [Guest]: Received "PERMISSION" event without payload',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -379,9 +433,15 @@ export default createPlugin<
|
||||
this.popups.host.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;
|
||||
}
|
||||
default: {
|
||||
@ -415,8 +475,10 @@ export default createPlugin<
|
||||
break;
|
||||
}
|
||||
case 'SYNC_PROGRESS': {
|
||||
if (this.permission === 'host-only') await this.connection?.broadcast('SYNC_QUEUE', undefined);
|
||||
else await this.connection?.broadcast('SYNC_PROGRESS', event.payload);
|
||||
if (this.permission === 'host-only')
|
||||
await this.connection?.broadcast('SYNC_QUEUE', undefined);
|
||||
else
|
||||
await this.connection?.broadcast('SYNC_PROGRESS', event.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -431,12 +493,16 @@ export default createPlugin<
|
||||
this.queue?.injection();
|
||||
this.queue?.setOwner({
|
||||
id: this.connection.id,
|
||||
...this.me
|
||||
...this.me,
|
||||
});
|
||||
|
||||
const progress = Array.from(document.querySelectorAll<HTMLElement & {
|
||||
_update: (...args: unknown[]) => void
|
||||
}>('tp-yt-paper-progress'));
|
||||
const progress = Array.from(
|
||||
document.querySelectorAll<
|
||||
HTMLElement & {
|
||||
_update: (...args: unknown[]) => void;
|
||||
}
|
||||
>('tp-yt-paper-progress'),
|
||||
);
|
||||
const rollbackList = progress.map((progress) => {
|
||||
const original = progress._update;
|
||||
progress._update = (...args) => {
|
||||
@ -444,10 +510,11 @@ export default createPlugin<
|
||||
|
||||
if (this.permission === 'all' && typeof now === 'number') {
|
||||
const currentTime = this.playerApi?.getCurrentTime() ?? 0;
|
||||
if (Math.abs(now - currentTime) > 3) this.connection?.broadcast('SYNC_PROGRESS', {
|
||||
progress: now,
|
||||
state: this.playerApi?.getPlayerState()
|
||||
});
|
||||
if (Math.abs(now - currentTime) > 3)
|
||||
this.connection?.broadcast('SYNC_PROGRESS', {
|
||||
progress: now,
|
||||
state: this.playerApi?.getPlayerState(),
|
||||
});
|
||||
}
|
||||
|
||||
original.call(progress, ...args);
|
||||
@ -466,8 +533,8 @@ export default createPlugin<
|
||||
id: this.connection.id,
|
||||
handleId: this.me.handleId,
|
||||
name: this.me.name,
|
||||
thumbnail: this.me.thumbnail
|
||||
}
|
||||
thumbnail: this.me.thumbnail,
|
||||
},
|
||||
});
|
||||
|
||||
this.connection.broadcast('SYNC_PROFILE', undefined);
|
||||
@ -525,14 +592,18 @@ export default createPlugin<
|
||||
},
|
||||
|
||||
initMyProfile() {
|
||||
const accountButton = document.querySelector<HTMLElement & {
|
||||
onButtonTap: () => void
|
||||
}>('ytmusic-settings-button');
|
||||
const accountButton = document.querySelector<
|
||||
HTMLElement & {
|
||||
onButtonTap: () => void;
|
||||
}
|
||||
>('ytmusic-settings-button');
|
||||
|
||||
accountButton?.onButtonTap();
|
||||
setTimeout(() => {
|
||||
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) {
|
||||
console.warn('Music Together: Cannot find account');
|
||||
this.me = getDefaultProfile(this.connection?.id ?? '');
|
||||
@ -543,7 +614,7 @@ export default createPlugin<
|
||||
this.me = {
|
||||
handleId: accountData.channelHandle.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) {
|
||||
@ -557,14 +628,23 @@ export default createPlugin<
|
||||
|
||||
start({ 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');
|
||||
|
||||
/* setup */
|
||||
document.querySelector('#right-content > ytmusic-settings-button')?.insertAdjacentHTML('beforebegin', settingHTML);
|
||||
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');
|
||||
document
|
||||
.querySelector('#right-content > ytmusic-settings-button')
|
||||
?.insertAdjacentHTML('beforebegin', settingHTML);
|
||||
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) {
|
||||
console.warn('Music Together: Cannot inject html');
|
||||
console.log(setting, icon, spinner);
|
||||
@ -574,7 +654,7 @@ export default createPlugin<
|
||||
this.elements = {
|
||||
setting,
|
||||
icon,
|
||||
spinner
|
||||
spinner,
|
||||
};
|
||||
|
||||
this.stateInterval = window.setInterval(() => {
|
||||
@ -584,7 +664,7 @@ export default createPlugin<
|
||||
this.connection.broadcast('SYNC_PROGRESS', {
|
||||
progress: this.playerApi?.getCurrentTime(),
|
||||
state: this.playerApi?.getPlayerState(),
|
||||
index
|
||||
index,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
@ -593,18 +673,25 @@ export default createPlugin<
|
||||
onItemClick: (id) => {
|
||||
if (id === 'music-together-close') {
|
||||
this.onStop();
|
||||
this.api?.toastService?.show(t('plugins.music-together.toast.closed'));
|
||||
this.api?.toastService?.show(
|
||||
t('plugins.music-together.toast.closed'),
|
||||
);
|
||||
hostPopup.dismiss();
|
||||
}
|
||||
|
||||
if (id === 'music-together-copy-id') {
|
||||
navigator.clipboard.writeText(this.connection?.id ?? '')
|
||||
navigator.clipboard
|
||||
.writeText(this.connection?.id ?? '')
|
||||
.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();
|
||||
})
|
||||
.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();
|
||||
});
|
||||
}
|
||||
@ -612,30 +699,39 @@ export default createPlugin<
|
||||
if (id === 'music-together-permission') {
|
||||
if (this.permission === 'all') this.permission = 'host-only';
|
||||
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);
|
||||
|
||||
hostPopup.setPermission(this.permission);
|
||||
guestPopup.setPermission(this.permission);
|
||||
settingPopup.setPermission(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 }));
|
||||
const permissionLabel = t(
|
||||
`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);
|
||||
if (item?.type === 'item') {
|
||||
item.setText(t('plugins.music-together.menu.set-permission'));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
const guestPopup = createGuestPopup({
|
||||
onItemClick: (id) => {
|
||||
if (id === 'music-together-disconnect') {
|
||||
this.onStop();
|
||||
this.api?.toastService?.show(t('plugins.music-together.toast.disconnected'));
|
||||
this.api?.toastService?.show(
|
||||
t('plugins.music-together.toast.disconnected'),
|
||||
);
|
||||
guestPopup.dismiss();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
const settingPopup = createSettingPopup({
|
||||
onItemClick: async (id) => {
|
||||
@ -646,16 +742,24 @@ export default createPlugin<
|
||||
this.hideSpinner();
|
||||
|
||||
if (result) {
|
||||
navigator.clipboard.writeText(this.connection?.id ?? '')
|
||||
navigator.clipboard
|
||||
.writeText(this.connection?.id ?? '')
|
||||
.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);
|
||||
}).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);
|
||||
});
|
||||
} 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();
|
||||
|
||||
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);
|
||||
} 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 = {
|
||||
host: hostPopup,
|
||||
guest: guestPopup,
|
||||
setting: settingPopup
|
||||
setting: settingPopup,
|
||||
};
|
||||
setting.addEventListener('click', () => {
|
||||
let popup = settingPopup;
|
||||
@ -695,24 +803,38 @@ export default createPlugin<
|
||||
this.queue = new Queue({
|
||||
owner: {
|
||||
id: this.connection?.id ?? '',
|
||||
...this.me!
|
||||
...this.me!,
|
||||
},
|
||||
getProfile: (id) => this.profiles[id]
|
||||
getProfile: (id) => this.profiles[id],
|
||||
});
|
||||
this.playerApi = playerApi;
|
||||
|
||||
this.playerApi.addEventListener('onStateChange', this.videoStateChangeListener);
|
||||
this.playerApi.addEventListener(
|
||||
'onStateChange',
|
||||
this.videoStateChangeListener,
|
||||
);
|
||||
document.addEventListener('videodatachange', this.videoChangeListener);
|
||||
},
|
||||
stop() {
|
||||
const dividers = Array.from(document.querySelectorAll('.music-together-divider'));
|
||||
const dividers = Array.from(
|
||||
document.querySelectorAll('.music-together-divider'),
|
||||
);
|
||||
dividers.forEach((divider) => divider.remove());
|
||||
|
||||
this.elements.setting?.remove();
|
||||
this.onStop();
|
||||
if (typeof this.stateInterval === 'number') clearInterval(this.stateInterval);
|
||||
if (this.playerApi) this.playerApi.removeEventListener('onStateChange', this.videoStateChangeListener);
|
||||
if (this.videoChangeListener) document.removeEventListener('videodatachange', this.videoChangeListener);
|
||||
}
|
||||
}
|
||||
if (typeof this.stateInterval === 'number')
|
||||
clearInterval(this.stateInterval);
|
||||
if (this.playerApi)
|
||||
this.playerApi.removeEventListener(
|
||||
'onStateChange',
|
||||
this.videoStateChangeListener,
|
||||
);
|
||||
if (this.videoChangeListener)
|
||||
document.removeEventListener(
|
||||
'videodatachange',
|
||||
this.videoChangeListener,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
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') =>
|
||||
(await SHA1Hash(`${millis} ${papisid} ${origin}`)).toLowerCase();
|
||||
export const getHash = async (
|
||||
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)}`;
|
||||
};
|
||||
|
||||
@ -23,15 +32,17 @@ export const getClient = () => {
|
||||
platform: 'DESKTOP',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
locationInfo: {
|
||||
locationPermissionAuthorizationStatus: 'LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED',
|
||||
locationPermissionAuthorizationStatus:
|
||||
'LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED',
|
||||
},
|
||||
musicAppInfo: {
|
||||
pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
|
||||
webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
|
||||
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(),
|
||||
};
|
||||
};
|
||||
|
||||
@ -54,46 +54,46 @@ const getHeaderPayload = (() => {
|
||||
title: {
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.music-together.internal.track-source')
|
||||
}
|
||||
]
|
||||
text: t('plugins.music-together.internal.track-source'),
|
||||
},
|
||||
],
|
||||
},
|
||||
subtitle: {
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.music-together.name')
|
||||
}
|
||||
]
|
||||
text: t('plugins.music-together.name'),
|
||||
},
|
||||
],
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
chipCloudChipRenderer: {
|
||||
style: {
|
||||
styleType: 'STYLE_TRANSPARENT'
|
||||
styleType: 'STYLE_TRANSPARENT',
|
||||
},
|
||||
text: {
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.music-together.internal.save')
|
||||
}
|
||||
]
|
||||
text: t('plugins.music-together.internal.save'),
|
||||
},
|
||||
],
|
||||
},
|
||||
navigationEndpoint: {
|
||||
saveQueueToPlaylistCommand: {}
|
||||
saveQueueToPlaylistCommand: {},
|
||||
},
|
||||
icon: {
|
||||
iconType: 'ADD_TO_PLAYLIST'
|
||||
iconType: 'ADD_TO_PLAYLIST',
|
||||
},
|
||||
accessibilityData: {
|
||||
accessibilityData: {
|
||||
label: t('plugins.music-together.internal.save')
|
||||
}
|
||||
label: t('plugins.music-together.internal.save'),
|
||||
},
|
||||
},
|
||||
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;
|
||||
queue?: QueueElement;
|
||||
getProfile: (id: string) => Profile | undefined;
|
||||
}
|
||||
};
|
||||
export type QueueEventListener = (event: ConnectionEventUnion) => void;
|
||||
|
||||
export class Queue {
|
||||
@ -114,7 +114,7 @@ export class Queue {
|
||||
|
||||
private originalDispatch?: (obj: {
|
||||
type: string;
|
||||
payload?: { items?: QueueItem[] | undefined; };
|
||||
payload?: { items?: QueueItem[] | undefined };
|
||||
}) => void;
|
||||
|
||||
private internalDispatch = false;
|
||||
@ -126,7 +126,8 @@ export class Queue {
|
||||
|
||||
constructor(options: QueueOptions) {
|
||||
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._videoList = options.videoList ?? [];
|
||||
}
|
||||
@ -139,7 +140,12 @@ export class Queue {
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -162,7 +168,9 @@ export class Queue {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const items = response.queueDatas.map((it) => it?.content).filter(Boolean);
|
||||
@ -173,12 +181,16 @@ export class Queue {
|
||||
this.queue?.dispatch({
|
||||
type: 'ADD_ITEMS',
|
||||
payload: {
|
||||
nextQueueItemId: this.queue.queue.store.store.getState().queue.nextQueueItemId,
|
||||
index: index ?? this.queue.queue.store.store.getState().queue.items.length ?? 0,
|
||||
nextQueueItemId:
|
||||
this.queue.queue.store.store.getState().queue.nextQueueItemId,
|
||||
index:
|
||||
index ??
|
||||
this.queue.queue.store.store.getState().queue.items.length ??
|
||||
0,
|
||||
items,
|
||||
shuffleEnabled: false,
|
||||
shouldAssignIds: true
|
||||
}
|
||||
shouldAssignIds: true,
|
||||
},
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
@ -194,7 +206,7 @@ export class Queue {
|
||||
this._videoList.splice(index, 1);
|
||||
this.queue?.dispatch({
|
||||
type: 'REMOVE_ITEM',
|
||||
payload: index
|
||||
payload: index,
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
@ -207,7 +219,7 @@ export class Queue {
|
||||
this.internalDispatch = true;
|
||||
this.queue?.dispatch({
|
||||
type: 'SET_INDEX',
|
||||
payload: index
|
||||
payload: index,
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
}
|
||||
@ -220,8 +232,8 @@ export class Queue {
|
||||
type: 'MOVE_ITEM',
|
||||
payload: {
|
||||
fromIndex,
|
||||
toIndex
|
||||
}
|
||||
toIndex,
|
||||
},
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
@ -234,7 +246,7 @@ export class Queue {
|
||||
this.internalDispatch = true;
|
||||
this._videoList = [];
|
||||
this.queue?.dispatch({
|
||||
type: 'CLEAR'
|
||||
type: 'CLEAR',
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
}
|
||||
@ -253,7 +265,8 @@ export class Queue {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.originalDispatch) this.queue.queue.store.store.dispatch = this.originalDispatch;
|
||||
if (this.originalDispatch)
|
||||
this.queue.queue.store.store.dispatch = this.originalDispatch;
|
||||
}
|
||||
|
||||
injection() {
|
||||
@ -276,40 +289,54 @@ export class Queue {
|
||||
if (event.type === 'ADD_ITEMS') {
|
||||
if (this.ignoreFlag) {
|
||||
this.ignoreFlag = false;
|
||||
const videoList = mapQueueItem((it) => ({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.owner!.id
|
||||
} satisfies VideoData), event.payload!.items!);
|
||||
const videoList = mapQueueItem(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.owner!.id,
|
||||
}) satisfies VideoData,
|
||||
event.payload!.items!,
|
||||
);
|
||||
const index = this._videoList.length + videoList.length - 1;
|
||||
|
||||
if (videoList.length > 0) {
|
||||
this.broadcast({ // play
|
||||
this.broadcast({
|
||||
// play
|
||||
type: 'ADD_SONGS',
|
||||
payload: {
|
||||
videoList
|
||||
videoList,
|
||||
},
|
||||
after: [
|
||||
{
|
||||
type: 'SYNC_PROGRESS',
|
||||
payload: {
|
||||
index
|
||||
}
|
||||
}
|
||||
]
|
||||
index,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if ((event.payload as {
|
||||
items: unknown[];
|
||||
}).items.length === 1) {
|
||||
this.broadcast({ // add playlist
|
||||
} else if (
|
||||
(
|
||||
event.payload as {
|
||||
items: unknown[];
|
||||
}
|
||||
).items.length === 1
|
||||
) {
|
||||
this.broadcast({
|
||||
// add playlist
|
||||
type: 'ADD_SONGS',
|
||||
payload: {
|
||||
// index: (event.payload as any).index,
|
||||
videoList: mapQueueItem((it) => ({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.owner!.id
|
||||
} satisfies VideoData), event.payload!.items!)
|
||||
}
|
||||
videoList: mapQueueItem(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.owner!.id,
|
||||
}) satisfies VideoData,
|
||||
event.payload!.items!,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -320,13 +347,17 @@ export class Queue {
|
||||
this.broadcast({
|
||||
type: 'MOVE_SONG',
|
||||
payload: {
|
||||
fromIndex: (event.payload as {
|
||||
fromIndex: number;
|
||||
}).fromIndex,
|
||||
toIndex: (event.payload as {
|
||||
toIndex: number;
|
||||
}).toIndex
|
||||
}
|
||||
fromIndex: (
|
||||
event.payload as {
|
||||
fromIndex: number;
|
||||
}
|
||||
).fromIndex,
|
||||
toIndex: (
|
||||
event.payload as {
|
||||
toIndex: number;
|
||||
}
|
||||
).toIndex,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -334,8 +365,8 @@ export class Queue {
|
||||
this.broadcast({
|
||||
type: 'REMOVE_SONG',
|
||||
payload: {
|
||||
index: event.payload as number
|
||||
}
|
||||
index: event.payload as number,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -343,8 +374,8 @@ export class Queue {
|
||||
this.broadcast({
|
||||
type: 'SYNC_PROGRESS',
|
||||
payload: {
|
||||
index: event.payload as number
|
||||
}
|
||||
index: event.payload as number,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -355,7 +386,10 @@ export class Queue {
|
||||
event.payload = undefined;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -370,7 +404,7 @@ export class Queue {
|
||||
store: {
|
||||
...this.queue.queue.store,
|
||||
dispatch: this.originalDispatch,
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
this.originalDispatch?.call(fakeContext, event);
|
||||
@ -384,20 +418,22 @@ export class Queue {
|
||||
this.internalDispatch = true;
|
||||
this.queue.dispatch({
|
||||
type: 'HAS_SHOWN_AUTOPLAY',
|
||||
payload: false
|
||||
payload: false,
|
||||
});
|
||||
this.queue.dispatch({
|
||||
type: 'SET_HEADER',
|
||||
payload: getHeaderPayload(),
|
||||
});
|
||||
this.queue.dispatch({
|
||||
type: 'CLEAR_STEERING_CHIPS'
|
||||
type: 'CLEAR_STEERING_CHIPS',
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const items = response.queueDatas.map((it) => it.content);
|
||||
@ -407,10 +443,11 @@ export class Queue {
|
||||
type: 'UPDATE_ITEMS',
|
||||
payload: {
|
||||
items: items,
|
||||
nextQueueItemId: this.queue.queue.store.store.getState().queue.nextQueueItemId,
|
||||
nextQueueItemId:
|
||||
this.queue.queue.store.store.getState().queue.nextQueueItemId,
|
||||
shouldAssignIds: true,
|
||||
currentIndex: -1
|
||||
}
|
||||
currentIndex: -1,
|
||||
},
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
@ -425,7 +462,9 @@ export class Queue {
|
||||
const allQueue = document.querySelectorAll('#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) => {
|
||||
if (typeof index !== 'number') return;
|
||||
@ -433,14 +472,19 @@ export class Queue {
|
||||
const id = this._videoList[index]?.ownerId;
|
||||
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.dataset.id = id;
|
||||
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.textContent = data?.name ?? t('plugins.music-together.internal.unknown-user');
|
||||
name.textContent =
|
||||
data?.name ?? t('plugins.music-together.internal.unknown-user');
|
||||
|
||||
if (data) {
|
||||
profile.dataset.thumbnail = data.thumbnail ?? '';
|
||||
@ -463,10 +507,14 @@ export class Queue {
|
||||
const allQueue = document.querySelectorAll('#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) => {
|
||||
const profile = item.querySelector<HTMLImageElement>('.music-together-owner');
|
||||
const profile = item.querySelector<HTMLImageElement>(
|
||||
'.music-together-owner',
|
||||
);
|
||||
const name = item.querySelector<HTMLElement>('.music-together-name');
|
||||
profile?.remove();
|
||||
name?.remove();
|
||||
|
||||
@ -8,7 +8,9 @@ type QueueRendererResponse = {
|
||||
trackingParams: string;
|
||||
};
|
||||
|
||||
export const getMusicQueueRenderer = async (videoIds: string[]): Promise<QueueRendererResponse | null> => {
|
||||
export const getMusicQueueRenderer = async (
|
||||
videoIds: string[],
|
||||
): Promise<QueueRendererResponse | null> => {
|
||||
const token = extractToken();
|
||||
if (!token) return null;
|
||||
|
||||
@ -35,8 +37,8 @@ export const getMusicQueueRenderer = async (videoIds: string[]): Promise<QueueRe
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://music.youtube.com',
|
||||
'Authorization': await getAuthorizationHeader(token),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
@ -1,21 +1,26 @@
|
||||
import {
|
||||
ItemPlaylistPanelVideoRenderer,
|
||||
PlaylistPanelVideoWrapperRenderer,
|
||||
QueueItem
|
||||
QueueItem,
|
||||
} from '@/types/datahost-get-state';
|
||||
|
||||
export const mapQueueItem = <T>(map: (item?: ItemPlaylistPanelVideoRenderer) => T, array: QueueItem[]): T[] => array
|
||||
.map((item) => {
|
||||
if ('playlistPanelVideoWrapperRenderer' in item) {
|
||||
const keys = Object.keys(item.playlistPanelVideoWrapperRenderer!.primaryRenderer) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[];
|
||||
return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]];
|
||||
}
|
||||
if ('playlistPanelVideoRenderer' in item) {
|
||||
return item.playlistPanelVideoRenderer;
|
||||
}
|
||||
|
||||
console.error('Music Together: Unknown item', item);
|
||||
return undefined;
|
||||
})
|
||||
.map(map);
|
||||
export const mapQueueItem = <T>(
|
||||
map: (item?: ItemPlaylistPanelVideoRenderer) => T,
|
||||
array: QueueItem[],
|
||||
): T[] =>
|
||||
array
|
||||
.map((item) => {
|
||||
if ('playlistPanelVideoWrapperRenderer' in item) {
|
||||
const keys = Object.keys(
|
||||
item.playlistPanelVideoWrapperRenderer!.primaryRenderer,
|
||||
) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[];
|
||||
return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]];
|
||||
}
|
||||
if ('playlistPanelVideoRenderer' in item) {
|
||||
return item.playlistPanelVideoRenderer;
|
||||
}
|
||||
|
||||
console.error('Music Together: Unknown item', item);
|
||||
return undefined;
|
||||
})
|
||||
.map(map);
|
||||
|
||||
@ -10,13 +10,16 @@ export type VideoData = {
|
||||
};
|
||||
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)}`;
|
||||
|
||||
return {
|
||||
id: connectionID,
|
||||
handleId: `#music-together:${id}`,
|
||||
name,
|
||||
thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`
|
||||
thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`,
|
||||
};
|
||||
};
|
||||
|
||||
@ -7,7 +7,6 @@ import { createStatus } from '../ui/status';
|
||||
|
||||
import IconOff from '../icons/off.svg?raw';
|
||||
|
||||
|
||||
export type GuestPopupProps = {
|
||||
onItemClick: (id: string) => void;
|
||||
};
|
||||
@ -33,7 +32,7 @@ export const createGuestPopup = (props: GuestPopupProps) => {
|
||||
},
|
||||
],
|
||||
anchorAt: 'bottom-right',
|
||||
popupAt: 'top-right'
|
||||
popupAt: 'top-right',
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -22,7 +22,7 @@ export const createHostPopup = (props: HostPopupProps) => {
|
||||
element: status.element,
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'music-together-copy-id',
|
||||
@ -35,7 +35,9 @@ export const createHostPopup = (props: HostPopupProps) => {
|
||||
id: 'music-together-permission',
|
||||
type: 'item',
|
||||
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'),
|
||||
},
|
||||
{
|
||||
|
||||
@ -39,7 +39,7 @@ export const createSettingPopup = (props: SettingPopupProps) => {
|
||||
},
|
||||
],
|
||||
anchorAt: 'bottom-right',
|
||||
popupAt: 'top-right'
|
||||
popupAt: 'top-right',
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -7,17 +7,27 @@ import type { Permission, Profile } from '../types';
|
||||
|
||||
export const createStatus = () => {
|
||||
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 statusLabel = element.querySelector<HTMLSpanElement>('#music-together-status-label')!;
|
||||
const permissionLabel = element.querySelector<HTMLMarqueeElement>('#music-together-permission-label')!;
|
||||
const profile = element.querySelector<HTMLImageElement>(
|
||||
'.music-together-profile',
|
||||
)!;
|
||||
const statusLabel = element.querySelector<HTMLSpanElement>(
|
||||
'#music-together-status-label',
|
||||
)!;
|
||||
const permissionLabel = element.querySelector<HTMLMarqueeElement>(
|
||||
'#music-together-permission-label',
|
||||
)!;
|
||||
|
||||
profile.src = icon?.src ?? '';
|
||||
|
||||
const setStatus = (status: 'disconnected' | 'host' | 'guest') => {
|
||||
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)';
|
||||
}
|
||||
|
||||
@ -34,17 +44,23 @@ export const createStatus = () => {
|
||||
|
||||
const setPermission = (permission: Permission) => {
|
||||
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)';
|
||||
}
|
||||
|
||||
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)';
|
||||
}
|
||||
|
||||
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)';
|
||||
}
|
||||
};
|
||||
@ -54,7 +70,9 @@ export const createStatus = () => {
|
||||
};
|
||||
|
||||
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')!;
|
||||
for (const child of Array.from(container.children)) {
|
||||
if (child !== empty) child.remove();
|
||||
|
||||
@ -68,7 +68,9 @@ const observer = new MutationObserver(() => {
|
||||
if (!menuUrl?.includes('watch?')) {
|
||||
menuUrl = undefined;
|
||||
// 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/')) {
|
||||
menuUrl = it.getAttribute('href')!;
|
||||
break;
|
||||
|
||||
@ -53,10 +53,7 @@ const observePopupContainer = () => {
|
||||
menu = getSongMenu();
|
||||
}
|
||||
|
||||
if (
|
||||
menu &&
|
||||
!menu.contains(slider)
|
||||
) {
|
||||
if (menu && !menu.contains(slider)) {
|
||||
menu.prepend(slider);
|
||||
setupSliderListener();
|
||||
}
|
||||
|
||||
@ -5,13 +5,13 @@ import { onMenu } from './menu';
|
||||
import { backend } from './main';
|
||||
|
||||
export interface ScrobblerPluginConfig {
|
||||
enabled: boolean,
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Attempt to scrobble other video types (e.g. Podcasts, normal YouTube videos)
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
scrobbleOtherMedia: boolean,
|
||||
* Attempt to scrobble other video types (e.g. Podcasts, normal YouTube videos)
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
scrobbleOtherMedia: boolean;
|
||||
scrobblers: {
|
||||
lastfm: {
|
||||
/**
|
||||
@ -19,53 +19,53 @@ export interface ScrobblerPluginConfig {
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
enabled: boolean,
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Token used for authentication
|
||||
*/
|
||||
token: string | undefined,
|
||||
token: string | undefined;
|
||||
/**
|
||||
* Session key used for scrobbling
|
||||
*/
|
||||
sessionKey: string | undefined,
|
||||
sessionKey: string | undefined;
|
||||
/**
|
||||
* Root of the Last.fm API
|
||||
*
|
||||
* @default 'http://ws.audioscrobbler.com/2.0/'
|
||||
*/
|
||||
apiRoot: string,
|
||||
apiRoot: string;
|
||||
/**
|
||||
* Last.fm api key registered by @semvis123
|
||||
*
|
||||
* @default '04d76faaac8726e60988e14c105d421a'
|
||||
*/
|
||||
apiKey: string,
|
||||
apiKey: string;
|
||||
/**
|
||||
* Last.fm api secret registered by @semvis123
|
||||
*
|
||||
* @default 'a5d2a36fdf64819290f6982481eaffa2'
|
||||
*/
|
||||
secret: string,
|
||||
},
|
||||
secret: string;
|
||||
};
|
||||
listenbrainz: {
|
||||
/**
|
||||
* Enable ListenBrainz scrobbling
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
enabled: boolean,
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Listenbrainz user token
|
||||
*/
|
||||
token: string | undefined,
|
||||
token: string | undefined;
|
||||
/**
|
||||
* Root of the ListenBrainz API
|
||||
*
|
||||
* @default 'https://api.listenbrainz.org/1/'
|
||||
*/
|
||||
apiRoot: string,
|
||||
},
|
||||
}
|
||||
apiRoot: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultConfig: ScrobblerPluginConfig = {
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
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 { LastFmScrobbler } from './services/lastfm';
|
||||
@ -13,14 +16,23 @@ export type SetConfType = (
|
||||
conf: Partial<Omit<ScrobblerPluginConfig, 'enabled'>>,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export const backend = createBackend<{
|
||||
config?: ScrobblerPluginConfig;
|
||||
window?: BrowserWindow;
|
||||
enabledScrobblers: Map<string, ScrobblerBase>;
|
||||
toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow): void;
|
||||
createSessions(config: ScrobblerPluginConfig, setConfig: SetConfType): Promise<void>;
|
||||
setConfig?: SetConfType;
|
||||
}, ScrobblerPluginConfig>({
|
||||
export const backend = createBackend<
|
||||
{
|
||||
config?: ScrobblerPluginConfig;
|
||||
window?: BrowserWindow;
|
||||
enabledScrobblers: Map<string, ScrobblerBase>;
|
||||
toggleScrobblers(
|
||||
config: ScrobblerPluginConfig,
|
||||
window: BrowserWindow,
|
||||
): void;
|
||||
createSessions(
|
||||
config: ScrobblerPluginConfig,
|
||||
setConfig: SetConfType,
|
||||
): Promise<void>;
|
||||
setConfig?: SetConfType;
|
||||
},
|
||||
ScrobblerPluginConfig
|
||||
>({
|
||||
enabledScrobblers: new Map(),
|
||||
|
||||
toggleScrobblers(config: ScrobblerPluginConfig, window: BrowserWindow) {
|
||||
@ -30,7 +42,10 @@ export const backend = createBackend<{
|
||||
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());
|
||||
} else {
|
||||
this.enabledScrobblers.delete('listenbrainz');
|
||||
@ -45,12 +60,8 @@ export const backend = createBackend<{
|
||||
}
|
||||
},
|
||||
|
||||
async start({
|
||||
getConfig,
|
||||
setConfig,
|
||||
window,
|
||||
}) {
|
||||
const config = this.config = await getConfig();
|
||||
async start({ getConfig, setConfig, window }) {
|
||||
const config = (this.config = await getConfig());
|
||||
// This will store the timeout that will trigger addScrobble
|
||||
let scrobbleTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
@ -65,21 +76,38 @@ export const backend = createBackend<{
|
||||
if (!songInfo.isPaused) {
|
||||
const configNonnull = this.config!;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
// Scrobble still needs to happen
|
||||
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
||||
scrobbleTimer = setTimeout((info, config) => {
|
||||
this.enabledScrobblers.forEach((scrobbler) => scrobbler.addScrobble(info, config, setConfig));
|
||||
}, timeToWait, songInfo, configNonnull);
|
||||
const timeToWait =
|
||||
(scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
||||
scrobbleTimer = setTimeout(
|
||||
(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.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) {
|
||||
const scrobbler = this.enabledScrobblers.get(scrobblerName);
|
||||
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.isSessionCreated(newConfig) &&
|
||||
this.setConfig
|
||||
@ -103,6 +135,5 @@ export const backend = createBackend<{
|
||||
}
|
||||
|
||||
this.config = newConfig;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -11,7 +11,11 @@ import { SetConfType, backend } from './main';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
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(
|
||||
{
|
||||
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'),
|
||||
value: options.scrobblers.lastfm?.apiKey,
|
||||
inputAttrs: {
|
||||
type: 'text'
|
||||
}
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.scrobbler.prompt.lastfm.api-secret'),
|
||||
value: options.scrobblers.lastfm?.secret,
|
||||
inputAttrs: {
|
||||
type: 'text'
|
||||
}
|
||||
}
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
],
|
||||
resizable: true,
|
||||
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(
|
||||
{
|
||||
title: t('plugins.scrobbler.prompt.listenbrainz.token.title'),
|
||||
|
||||
@ -5,9 +5,20 @@ import type { SongInfo } from '@/providers/song-info';
|
||||
export abstract class ScrobblerBase {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -81,7 +81,11 @@ export class LastFmScrobbler extends ScrobblerBase {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@ -93,7 +97,11 @@ export class LastFmScrobbler extends ScrobblerBase {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@ -101,7 +109,9 @@ export class LastFmScrobbler extends ScrobblerBase {
|
||||
// This adds one scrobbled song to last.fm
|
||||
const data = {
|
||||
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);
|
||||
}
|
||||
@ -195,8 +205,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
||||
// This function creates the api signature, see: https://www.last.fm/api/authspec
|
||||
let sig = '';
|
||||
|
||||
Object
|
||||
.entries(parameters)
|
||||
Object.entries(parameters)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.forEach(([key, value]) => {
|
||||
if (key === 'format') {
|
||||
@ -212,12 +221,8 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
||||
|
||||
const createToken = async ({
|
||||
scrobblers: {
|
||||
lastfm: {
|
||||
apiKey,
|
||||
apiRoot,
|
||||
secret,
|
||||
}
|
||||
}
|
||||
lastfm: { apiKey, apiRoot, secret },
|
||||
},
|
||||
}: ScrobblerPluginConfig) => {
|
||||
// Creates and stores the auth token
|
||||
const data: {
|
||||
@ -240,7 +245,10 @@ const createToken = async ({
|
||||
let authWindowOpened = false;
|
||||
let latestAuthResult = false;
|
||||
|
||||
const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWindow) => {
|
||||
const authenticate = async (
|
||||
config: ScrobblerPluginConfig,
|
||||
mainWindow: BrowserWindow,
|
||||
) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (!authWindowOpened) {
|
||||
authWindowOpened = true;
|
||||
@ -266,9 +274,10 @@ const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWi
|
||||
const url = new URL(newUrl);
|
||||
if (url.hostname.endsWith('last.fm')) {
|
||||
if (url.pathname === '/api/auth') {
|
||||
const isApproveScreen = await browserWindow.webContents.executeJavaScript(
|
||||
'!!document.getElementsByName(\'confirm\').length'
|
||||
) as boolean;
|
||||
const isApproveScreen =
|
||||
(await browserWindow.webContents.executeJavaScript(
|
||||
"!!document.getElementsByName('confirm').length",
|
||||
)) as boolean;
|
||||
// successful authentication
|
||||
if (!isApproveScreen) {
|
||||
resolve(true);
|
||||
@ -287,7 +296,7 @@ const authenticate = async (config: ScrobblerPluginConfig, mainWindow: BrowserWi
|
||||
dialog.showMessageBox({
|
||||
title: t('plugins.scrobbler.dialog.lastfm.auth-failed.title'),
|
||||
message: t('plugins.scrobbler.dialog.lastfm.auth-failed.message'),
|
||||
type: 'error'
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
authWindowOpened = false;
|
||||
|
||||
@ -29,12 +29,22 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
|
||||
return true;
|
||||
}
|
||||
|
||||
override createSession(config: ScrobblerPluginConfig, _setConfig: SetConfType): Promise<ScrobblerPluginConfig> {
|
||||
override createSession(
|
||||
config: ScrobblerPluginConfig,
|
||||
_setConfig: SetConfType,
|
||||
): Promise<ScrobblerPluginConfig> {
|
||||
return Promise.resolve(config);
|
||||
}
|
||||
|
||||
override setNowPlaying(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void {
|
||||
if (!config.scrobblers.listenbrainz.apiRoot || !config.scrobblers.listenbrainz.token) {
|
||||
override setNowPlaying(
|
||||
songInfo: SongInfo,
|
||||
config: ScrobblerPluginConfig,
|
||||
_setConfig: SetConfType,
|
||||
): void {
|
||||
if (
|
||||
!config.scrobblers.listenbrainz.apiRoot ||
|
||||
!config.scrobblers.listenbrainz.token
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -42,8 +52,15 @@ export class ListenbrainzScrobbler extends ScrobblerBase {
|
||||
submitListen(body, config);
|
||||
}
|
||||
|
||||
override addScrobble(songInfo: SongInfo, config: ScrobblerPluginConfig, _setConfig: SetConfType): void {
|
||||
if (!config.scrobblers.listenbrainz.apiRoot || !config.scrobblers.listenbrainz.token) {
|
||||
override addScrobble(
|
||||
songInfo: SongInfo,
|
||||
config: ScrobblerPluginConfig,
|
||||
_setConfig: SetConfType,
|
||||
): void {
|
||||
if (
|
||||
!config.scrobblers.listenbrainz.apiRoot ||
|
||||
!config.scrobblers.listenbrainz.token
|
||||
) {
|
||||
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 = {
|
||||
artist_name: songInfo.artist,
|
||||
track_name: songInfo.title,
|
||||
@ -64,7 +84,7 @@ function createRequestBody(listenType: string, songInfo: SongInfo): Listenbrainz
|
||||
submission_client: 'YouTube Music Desktop App - Scrobbler Plugin',
|
||||
origin_url: songInfo.url,
|
||||
duration: songInfo.songDuration,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
@ -72,19 +92,23 @@ function createRequestBody(listenType: string, songInfo: SongInfo): Listenbrainz
|
||||
payload: [
|
||||
{
|
||||
track_metadata: trackMetadata,
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function submitListen(body: ListenbrainzRequestBody, config: ScrobblerPluginConfig) {
|
||||
net.fetch(config.scrobblers.listenbrainz.apiRoot + 'submit-listens',
|
||||
{
|
||||
function submitListen(
|
||||
body: ListenbrainzRequestBody,
|
||||
config: ScrobblerPluginConfig,
|
||||
) {
|
||||
net
|
||||
.fetch(config.scrobblers.listenbrainz.apiRoot + 'submit-listens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'Authorization': 'Token ' + config.scrobblers.listenbrainz.token,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}).catch(console.error);
|
||||
},
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
12
src/plugins/shortcuts/mpris-service.d.ts
vendored
12
src/plugins/shortcuts/mpris-service.d.ts
vendored
@ -176,15 +176,17 @@ declare module '@jellybrick/mpris-service' {
|
||||
setActivePlaylist(playlistId: string): void;
|
||||
}
|
||||
|
||||
interface MprisInterface extends dbusInterface.Interface {
|
||||
export interface MprisInterface extends dbusInterface.Interface {
|
||||
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;
|
||||
|
||||
TrackAdded(afterTrack: string): void;
|
||||
@ -192,7 +194,7 @@ declare module '@jellybrick/mpris-service' {
|
||||
TrackRemoved(trackId: string): void;
|
||||
}
|
||||
|
||||
interface PlaylistsInterface {
|
||||
export interface PlaylistsInterface {
|
||||
PlaylistChanged(playlist: unknown[]): void;
|
||||
|
||||
setActivePlaylistId(playlistId: string): void;
|
||||
|
||||
@ -192,10 +192,13 @@ function registerMPRIS(win: BrowserWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPosition = queue.items?.findIndex((it) =>
|
||||
it?.playlistPanelVideoRenderer?.selected ||
|
||||
it?.playlistPanelVideoWrapperRenderer?.primaryRenderer?.playlistPanelVideoRenderer?.selected
|
||||
) ?? 0;
|
||||
const currentPosition =
|
||||
queue.items?.findIndex(
|
||||
(it) =>
|
||||
it?.playlistPanelVideoRenderer?.selected ||
|
||||
it?.playlistPanelVideoWrapperRenderer?.primaryRenderer
|
||||
?.playlistPanelVideoRenderer?.selected,
|
||||
) ?? 0;
|
||||
player.canGoPrevious = currentPosition !== 0;
|
||||
|
||||
let hasNext: boolean;
|
||||
|
||||
@ -16,20 +16,24 @@ export default createPlugin<
|
||||
restartNeeded: false,
|
||||
renderer: {
|
||||
start() {
|
||||
waitForElement<HTMLElement>('#dislike-button-renderer').then((dislikeBtn) => {
|
||||
this.observer = new MutationObserver(() => {
|
||||
if (dislikeBtn?.getAttribute('like-status') == 'DISLIKE') {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('tp-yt-paper-icon-button.next-button')
|
||||
?.click();
|
||||
}
|
||||
});
|
||||
this.observer.observe(dislikeBtn, {
|
||||
attributes: true,
|
||||
childList: false,
|
||||
subtree: false,
|
||||
});
|
||||
});
|
||||
waitForElement<HTMLElement>('#dislike-button-renderer').then(
|
||||
(dislikeBtn) => {
|
||||
this.observer = new MutationObserver(() => {
|
||||
if (dislikeBtn?.getAttribute('like-status') == 'DISLIKE') {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'tp-yt-paper-icon-button.next-button',
|
||||
)
|
||||
?.click();
|
||||
}
|
||||
});
|
||||
this.observer.observe(dislikeBtn, {
|
||||
attributes: true,
|
||||
childList: false,
|
||||
subtree: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
stop() {
|
||||
this.observer?.disconnect();
|
||||
|
||||
@ -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');
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const { sortSegments } = require('../segments');
|
||||
|
||||
test('Segment sorting', () => {
|
||||
|
||||
@ -31,8 +31,12 @@ export const menu = async ({
|
||||
type: 'submenu',
|
||||
submenu: [
|
||||
{
|
||||
label: t('plugins.synced-lyrics.menu.line-effect.submenu.scale.label'),
|
||||
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.scale.tooltip'),
|
||||
label: t(
|
||||
'plugins.synced-lyrics.menu.line-effect.submenu.scale.label',
|
||||
),
|
||||
toolTip: t(
|
||||
'plugins.synced-lyrics.menu.line-effect.submenu.scale.tooltip',
|
||||
),
|
||||
type: 'radio',
|
||||
checked: config.lineEffect === 'scale',
|
||||
click() {
|
||||
@ -42,8 +46,12 @@ export const menu = async ({
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.synced-lyrics.menu.line-effect.submenu.offset.label'),
|
||||
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.offset.tooltip'),
|
||||
label: t(
|
||||
'plugins.synced-lyrics.menu.line-effect.submenu.offset.label',
|
||||
),
|
||||
toolTip: t(
|
||||
'plugins.synced-lyrics.menu.line-effect.submenu.offset.tooltip',
|
||||
),
|
||||
type: 'radio',
|
||||
checked: config.lineEffect === 'offset',
|
||||
click() {
|
||||
@ -53,8 +61,12 @@ export const menu = async ({
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.synced-lyrics.menu.line-effect.submenu.focus.label'),
|
||||
toolTip: t('plugins.synced-lyrics.menu.line-effect.submenu.focus.tooltip'),
|
||||
label: t(
|
||||
'plugins.synced-lyrics.menu.line-effect.submenu.focus.label',
|
||||
),
|
||||
toolTip: t(
|
||||
'plugins.synced-lyrics.menu.line-effect.submenu.focus.tooltip',
|
||||
),
|
||||
type: 'radio',
|
||||
checked: config.lineEffect === 'focus',
|
||||
click() {
|
||||
@ -125,7 +137,9 @@ export const menu = async ({
|
||||
},
|
||||
{
|
||||
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',
|
||||
checked: config.showLyricsEvenIfInexact,
|
||||
click(item) {
|
||||
|
||||
@ -28,7 +28,7 @@ export const LyricsContainer = () => {
|
||||
|
||||
const info = getSongInfo();
|
||||
await makeLyricsRequest(info).catch((err) => {
|
||||
setError(`${err}`);
|
||||
setError(String(err));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -14,12 +14,15 @@ import type { SyncedLyricsPluginConfig } from '../types';
|
||||
|
||||
export let _ytAPI: YoutubePlayer | null = null;
|
||||
|
||||
export const renderer = createRenderer<{
|
||||
observerCallback: MutationCallback;
|
||||
observer?: MutationObserver;
|
||||
videoDataChange: () => Promise<void>;
|
||||
updateTimestampInterval?: NodeJS.Timeout | string | number;
|
||||
}, SyncedLyricsPluginConfig>({
|
||||
export const renderer = createRenderer<
|
||||
{
|
||||
observerCallback: MutationCallback;
|
||||
observer?: MutationObserver;
|
||||
videoDataChange: () => Promise<void>;
|
||||
updateTimestampInterval?: NodeJS.Timeout | string | number;
|
||||
},
|
||||
SyncedLyricsPluginConfig
|
||||
>({
|
||||
onConfigChange(newConfig) {
|
||||
setConfig(newConfig);
|
||||
},
|
||||
@ -57,9 +60,7 @@ export const renderer = createRenderer<{
|
||||
);
|
||||
}
|
||||
|
||||
this.observer ??= new MutationObserver(
|
||||
this.observerCallback,
|
||||
);
|
||||
this.observer ??= new MutationObserver(this.observerCallback);
|
||||
|
||||
// Force the lyrics tab to be enabled at all times.
|
||||
this.observer.disconnect();
|
||||
|
||||
@ -27,7 +27,7 @@ export const extractTimeAndText = (
|
||||
parseInt(rMillis),
|
||||
];
|
||||
|
||||
const timeInMs = (minutes * 60 * 1000) + (seconds * 1000) + millis;
|
||||
const timeInMs = minutes * 60 * 1000 + seconds * 1000 + millis;
|
||||
|
||||
return {
|
||||
index,
|
||||
@ -75,7 +75,6 @@ export const getLyricsList = async (
|
||||
track_name: songData.title,
|
||||
});
|
||||
|
||||
|
||||
if (songData.album) {
|
||||
query.set('album_name', songData.album);
|
||||
}
|
||||
@ -88,7 +87,7 @@ export const getLyricsList = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = await response.json() as LRCLIBSearchResponse;
|
||||
let data = (await response.json()) as LRCLIBSearchResponse;
|
||||
if (!data || !Array.isArray(data)) {
|
||||
setDebugInfo('Unexpected server response.');
|
||||
return null;
|
||||
@ -127,7 +126,10 @@ export const getLyricsList = async (
|
||||
const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim());
|
||||
|
||||
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)));
|
||||
@ -148,7 +150,7 @@ export const getLyricsList = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
setDebugInfo(JSON.stringify(closestResult, null, 4));
|
||||
setDebugInfo(JSON.stringify(closestResult, null, 4));
|
||||
|
||||
if (Math.abs(closestResult.duration - duration) > 15) {
|
||||
return null;
|
||||
|
||||
@ -7,8 +7,11 @@ import type { SyncedLyricsPluginConfig } from '../types';
|
||||
|
||||
export const [isVisible, setIsVisible] = createSignal<boolean>(false);
|
||||
|
||||
export const [config, setConfig] = createSignal<SyncedLyricsPluginConfig | null>(null);
|
||||
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(null);
|
||||
export const [config, setConfig] =
|
||||
createSignal<SyncedLyricsPluginConfig | null>(null);
|
||||
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const LyricsRenderer = () => {
|
||||
return (
|
||||
|
||||
@ -92,7 +92,9 @@ export default createPlugin({
|
||||
|
||||
// Get image source
|
||||
songImage.icon = (
|
||||
songInfo.image ? songInfo.image : nativeImage.createFromPath(youtubeMusicIcon)
|
||||
songInfo.image
|
||||
? songInfo.image
|
||||
: nativeImage.createFromPath(youtubeMusicIcon)
|
||||
).resize({ height: 23 });
|
||||
|
||||
window.setTouchBar(touchBar);
|
||||
|
||||
@ -6,8 +6,8 @@ export const getNetFetchAsFetch = () =>
|
||||
typeof input === 'string'
|
||||
? new URL(input)
|
||||
: input instanceof URL
|
||||
? input
|
||||
: new URL(input.url);
|
||||
? input
|
||||
: new URL(input.url);
|
||||
|
||||
if (init?.body && !init.method) {
|
||||
init.method = 'POST';
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent, webFrame } from 'electron';
|
||||
import {
|
||||
contextBridge,
|
||||
ipcRenderer,
|
||||
IpcRendererEvent,
|
||||
webFrame,
|
||||
} from 'electron';
|
||||
import is from 'electron-is';
|
||||
|
||||
import config from './config';
|
||||
@ -48,23 +53,33 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
sendToHost: (channel: string, ...args: unknown[]) =>
|
||||
ipcRenderer.sendToHost(channel, ...args),
|
||||
});
|
||||
contextBridge.exposeInMainWorld('reload', () => ipcRenderer.send('ytmd:reload'));
|
||||
contextBridge.exposeInMainWorld('reload', () =>
|
||||
ipcRenderer.send('ytmd:reload'),
|
||||
);
|
||||
contextBridge.exposeInMainWorld(
|
||||
'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;
|
||||
if (path) {
|
||||
webFrame.executeJavaScriptInIsolatedWorld(0, [
|
||||
{
|
||||
code: script,
|
||||
url: path,
|
||||
},
|
||||
], true, () => blocked = false);
|
||||
webFrame.executeJavaScriptInIsolatedWorld(
|
||||
0,
|
||||
[
|
||||
{
|
||||
code: script,
|
||||
url: path,
|
||||
},
|
||||
],
|
||||
true,
|
||||
() => (blocked = false),
|
||||
);
|
||||
} else {
|
||||
webFrame.executeJavaScript(script, true, () => blocked = false);
|
||||
webFrame.executeJavaScript(script, true, () => (blocked = false));
|
||||
}
|
||||
|
||||
// HACK: Wait for the script to be executed
|
||||
|
||||
@ -9,10 +9,8 @@ export const restart = () => restartInternal();
|
||||
export const setupAppControls = () => {
|
||||
ipcMain.on('ytmd:restart', restart);
|
||||
ipcMain.handle('ytmd:get-downloads-folder', () => app.getPath('downloads'));
|
||||
ipcMain.on(
|
||||
'ytmd:reload',
|
||||
() =>
|
||||
BrowserWindow.getFocusedWindow()?.webContents.loadURL(config.get('url')),
|
||||
ipcMain.on('ytmd:reload', () =>
|
||||
BrowserWindow.getFocusedWindow()?.webContents.loadURL(config.get('url')),
|
||||
);
|
||||
ipcMain.handle('ytmd:get-path', (_, ...args: string[]) => path.join(...args));
|
||||
};
|
||||
|
||||
@ -80,7 +80,7 @@ function memoize<T extends (...params: unknown[]) => unknown>(fn: T): T {
|
||||
cache.set(key, fn(...args));
|
||||
}
|
||||
|
||||
return cache.get(key) as unknown;
|
||||
return cache.get(key);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,9 @@ import getSongControls from './song-controls';
|
||||
|
||||
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) {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
@ -19,7 +21,10 @@ export function setupProtocolHandler(win: BrowserWindow) {
|
||||
|
||||
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)) {
|
||||
songControls[cmd](args as never);
|
||||
}
|
||||
@ -30,7 +35,9 @@ export function handleProtocol(cmd: string, args: string[] | undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,11 @@ import { singleton } from './decorators';
|
||||
|
||||
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||
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 { VideoDataChanged } from '@/types/video-data-changed';
|
||||
@ -10,9 +14,12 @@ import type { VideoDataChanged } from '@/types/video-data-changed';
|
||||
let songInfo: SongInfo = {} as SongInfo;
|
||||
export const getSongInfo = () => songInfo;
|
||||
|
||||
window.ipcRenderer.on('ytmd:update-song-info', (_, extractedSongInfo: SongInfo) => {
|
||||
songInfo = extractedSongInfo;
|
||||
});
|
||||
window.ipcRenderer.on(
|
||||
'ytmd:update-song-info',
|
||||
(_, extractedSongInfo: SongInfo) => {
|
||||
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)
|
||||
const srcChangedEvent = new CustomEvent('ytmd:src-changed');
|
||||
@ -91,9 +98,8 @@ export const setupFullScreenChangedListener = singleton(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
window.ipcRenderer.send(
|
||||
'ytmd:fullscreen-changed',
|
||||
(
|
||||
playerBar?.attributes.getNamedItem('player-fullscreened') ?? null
|
||||
) !== null,
|
||||
(playerBar?.attributes.getNamedItem('player-fullscreened') ?? null) !==
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@ -203,15 +209,19 @@ export default (api: YoutubePlayer) => {
|
||||
|
||||
if (!isNaN(video.duration)) {
|
||||
const {
|
||||
title, author,
|
||||
title,
|
||||
author,
|
||||
video_id: videoId,
|
||||
list: playlistId
|
||||
list: playlistId,
|
||||
} = api.getVideoData();
|
||||
|
||||
const watchNextResponse = api.getWatchNextResponse();
|
||||
|
||||
sendSongInfo({
|
||||
title, author, videoId, playlistId,
|
||||
title,
|
||||
author,
|
||||
videoId,
|
||||
playlistId,
|
||||
|
||||
isUpcoming: false,
|
||||
lengthSeconds: video.duration,
|
||||
@ -236,9 +246,10 @@ export default (api: YoutubePlayer) => {
|
||||
} else {
|
||||
playerOverlay = videoData.ytmdWatchNextResponse?.playerOverlays;
|
||||
}
|
||||
data.videoDetails.album = playerOverlay?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album?.runs?.at(
|
||||
0,
|
||||
)?.text;
|
||||
data.videoDetails.album =
|
||||
playerOverlay?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album?.runs?.at(
|
||||
0,
|
||||
)?.text;
|
||||
data.videoDetails.elapsedSeconds = 0;
|
||||
data.videoDetails.isPaused = false;
|
||||
|
||||
|
||||
@ -47,9 +47,7 @@ export interface SongInfo {
|
||||
export const getImage = async (src: string): Promise<Electron.NativeImage> => {
|
||||
const result = await net.fetch(src);
|
||||
const output = nativeImage.createFromBuffer(
|
||||
Buffer.from(
|
||||
await result.arrayBuffer(),
|
||||
),
|
||||
Buffer.from(await result.arrayBuffer()),
|
||||
);
|
||||
if (output.isEmpty() && !src.endsWith('.jpg') && src.includes('.jpg')) {
|
||||
// Fix hidden webp files (https://github.com/th-ch/youtube-music/issues/315)
|
||||
|
||||
@ -23,7 +23,11 @@ let isPluginLoaded = false;
|
||||
let isApiLoaded = 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', {
|
||||
createHTML: (input) => input,
|
||||
createScriptURL: (input) => input,
|
||||
@ -48,10 +52,14 @@ interface YouTubeMusicAppElement extends HTMLElement {
|
||||
|
||||
async function onApiLoaded() {
|
||||
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', () => {
|
||||
document.querySelector<HTMLElement>('.next-button.ytmusic-player-bar')?.click();
|
||||
document
|
||||
.querySelector<HTMLElement>('.next-button.ytmusic-player-bar')
|
||||
?.click();
|
||||
});
|
||||
window.ipcRenderer.on('ytmd:play', (_) => {
|
||||
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-by', (_, t: number) => api!.seekBy(t));
|
||||
window.ipcRenderer.on('ytmd:shuffle', () => {
|
||||
document.querySelector<HTMLElement & { queue: { shuffle: () => void } }>('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);
|
||||
document
|
||||
.querySelector<
|
||||
HTMLElement & { queue: { shuffle: () => void } }
|
||||
>('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) => {
|
||||
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) => {
|
||||
@ -110,12 +133,19 @@ async function onApiLoaded() {
|
||||
event.sender.send('ytmd:set-fullscreen', isFullscreen());
|
||||
});
|
||||
|
||||
window.ipcRenderer.on('ytmd:click-fullscreen-button', (_, fullscreen: boolean | undefined) => {
|
||||
clickFullscreenButton(fullscreen ?? false);
|
||||
});
|
||||
window.ipcRenderer.on(
|
||||
'ytmd:click-fullscreen-button',
|
||||
(_, fullscreen: boolean | undefined) => {
|
||||
clickFullscreenButton(fullscreen ?? false);
|
||||
},
|
||||
);
|
||||
|
||||
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) => {
|
||||
@ -132,9 +162,7 @@ async function onApiLoaded() {
|
||||
const audioSource = audioContext.createMediaElementSource(video);
|
||||
audioSource.connect(audioContext.destination);
|
||||
|
||||
for await (const [id, plugin] of Object.entries(
|
||||
getAllLoadedRendererPlugins(),
|
||||
)) {
|
||||
for (const [id, plugin] of Object.entries(getAllLoadedRendererPlugins())) {
|
||||
if (typeof plugin.renderer !== 'function') {
|
||||
await plugin.renderer?.onPlayerApiReady?.call(
|
||||
plugin.renderer,
|
||||
@ -189,7 +217,9 @@ async function onApiLoaded() {
|
||||
const itemsSelector = 'ytmusic-guide-section-renderer #items';
|
||||
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) {
|
||||
const path = upgradeBtnIcon.firstChild as SVGPathElement;
|
||||
const data = path.getAttribute('d')!.substring(0, 15);
|
||||
|
||||
34
src/tray.ts
34
src/tray.ts
@ -49,15 +49,21 @@ export const setUpTray = (app: Electron.App, win: Electron.BrowserWindow) => {
|
||||
|
||||
const { playPause, next, previous } = getSongControls(win);
|
||||
|
||||
const pixelRatio = is.windows() ? screen.getPrimaryDisplay().scaleFactor || 1 : 1;
|
||||
const defaultTrayIcon = nativeImage.createFromPath(defaultTrayIconAsset).resize({
|
||||
width: 16 * pixelRatio,
|
||||
height: 16 * pixelRatio,
|
||||
});
|
||||
const pausedTrayIcon = nativeImage.createFromPath(pausedTrayIconAsset).resize({
|
||||
width: 16 * pixelRatio,
|
||||
height: 16 * pixelRatio,
|
||||
});
|
||||
const pixelRatio = is.windows()
|
||||
? screen.getPrimaryDisplay().scaleFactor || 1
|
||||
: 1;
|
||||
const defaultTrayIcon = nativeImage
|
||||
.createFromPath(defaultTrayIconAsset)
|
||||
.resize({
|
||||
width: 16 * pixelRatio,
|
||||
height: 16 * pixelRatio,
|
||||
});
|
||||
const pausedTrayIcon = nativeImage
|
||||
.createFromPath(pausedTrayIconAsset)
|
||||
.resize({
|
||||
width: 16 * pixelRatio,
|
||||
height: 16 * pixelRatio,
|
||||
});
|
||||
|
||||
tray = new Tray(defaultTrayIcon);
|
||||
|
||||
@ -126,10 +132,12 @@ export const setUpTray = (app: Electron.App, win: Electron.BrowserWindow) => {
|
||||
return;
|
||||
}
|
||||
|
||||
tray.setToolTip(t('main.tray.tooltip.with-song-info', {
|
||||
artist: songInfo.artist,
|
||||
title: songInfo.title,
|
||||
}));
|
||||
tray.setToolTip(
|
||||
t('main.tray.tooltip.with-song-info', {
|
||||
artist: songInfo.artist,
|
||||
title: songInfo.title,
|
||||
}),
|
||||
);
|
||||
|
||||
tray.setImage(songInfo.isPaused ? pausedTrayIcon : defaultTrayIcon);
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import type {
|
||||
import type { PluginConfig } from '@/types/plugins';
|
||||
|
||||
export interface BaseContext<Config extends PluginConfig> {
|
||||
getConfig(): Promise<Config> | Config;
|
||||
setConfig(conf: Partial<Omit<Config, 'enabled'>>): Promise<void> | void;
|
||||
getConfig: () => Promise<Config> | Config;
|
||||
setConfig: (conf: Partial<Omit<Config, 'enabled'>>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface BackendContext<Config extends PluginConfig>
|
||||
@ -29,6 +29,7 @@ export interface MenuContext<Config extends PluginConfig>
|
||||
refresh: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface PreloadContext<Config extends PluginConfig>
|
||||
extends BaseContext<Config> {}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ export interface Download {
|
||||
isLeaderTab: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface Entities {}
|
||||
|
||||
export interface LikeStatus {
|
||||
|
||||
@ -108,6 +108,7 @@ export interface Endpoint {
|
||||
watchEndpoint: WatchEndpoint;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface CommandMetadata {}
|
||||
|
||||
export interface WatchEndpoint {
|
||||
|
||||
@ -13,19 +13,16 @@ type Store = {
|
||||
getState: () => StoreState;
|
||||
replaceReducer: (param1: unknown) => unknown;
|
||||
subscribe: (callback: () => void) => unknown;
|
||||
}
|
||||
};
|
||||
|
||||
export type QueueElement = HTMLElement & {
|
||||
dispatch(obj: {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
}): void;
|
||||
dispatch(obj: { type: string; payload?: unknown }): void;
|
||||
queue: QueueAPI;
|
||||
};
|
||||
export type QueueAPI = {
|
||||
getItems(): QueueItem[];
|
||||
store: {
|
||||
store: Store,
|
||||
store: Store;
|
||||
};
|
||||
continuation?: string;
|
||||
autoPlaying?: boolean;
|
||||
|
||||
@ -297,10 +297,28 @@ export interface YoutubePlayer {
|
||||
handleGlobalKeyDown: () => void;
|
||||
handleGlobalKeyUp: () => void;
|
||||
wakeUpControls: () => void;
|
||||
cueVideoById: (videoId: string, startSeconds: number, 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;
|
||||
cueVideoById: (
|
||||
videoId: string,
|
||||
startSeconds: number,
|
||||
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.
|
||||
*/
|
||||
@ -361,7 +379,7 @@ export interface YoutubePlayer {
|
||||
name: K extends 'videodatachange' ? PlayerAPIEvents[K]['name'] : never,
|
||||
data: K extends 'videodatachange' ? PlayerAPIEvents[K]['value'] : never,
|
||||
) => void,
|
||||
options?: boolean | AddEventListenerOptions | undefined,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) => void;
|
||||
removeEventListener: <K extends keyof PlayerAPIEvents>(
|
||||
type: K,
|
||||
@ -370,7 +388,7 @@ export interface YoutubePlayer {
|
||||
name: K extends 'videodatachange' ? PlayerAPIEvents[K]['name'] : never,
|
||||
data: K extends 'videodatachange' ? PlayerAPIEvents[K]['value'] : never,
|
||||
) => void,
|
||||
options?: boolean | EventListenerOptions | undefined,
|
||||
options?: boolean | EventListenerOptions,
|
||||
) => void;
|
||||
getDebugText: () => string;
|
||||
addCueRange: <Parameters extends unknown[], Return>(
|
||||
@ -395,7 +413,11 @@ export interface YoutubePlayer {
|
||||
getMediaReferenceTime: () => number;
|
||||
getSize: () => { width: number; height: number };
|
||||
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>(
|
||||
...params: Parameters
|
||||
) => Return;
|
||||
|
||||
@ -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) => {
|
||||
const interval = setInterval(() => {
|
||||
const elem = document.querySelector<T>(selector);
|
||||
|
||||
3
src/yt-web-components.d.ts
vendored
3
src/yt-web-components.d.ts
vendored
@ -31,7 +31,8 @@ declare module 'solid-js' {
|
||||
interface IntrinsicElements {
|
||||
'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps;
|
||||
'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps;
|
||||
'tp-yt-paper-spinner-lite': ComponentProps<'div'> & YpYtPaperSpinnerLiteProps;
|
||||
'tp-yt-paper-spinner-lite': ComponentProps<'div'> &
|
||||
YpYtPaperSpinnerLiteProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,9 +17,7 @@ export const i18nImporter = () => {
|
||||
});
|
||||
|
||||
const srcPath = resolve(__dirname, '..', 'src');
|
||||
const plugins = globSync([
|
||||
'src/i18n/resources/*.json',
|
||||
]).map((path) => {
|
||||
const plugins = globSync(['src/i18n/resources/*.json']).map((path) => {
|
||||
const nameWithExt = basename(path);
|
||||
const name = nameWithExt.replace(extname(nameWithExt), '');
|
||||
|
||||
|
||||
@ -49,7 +49,9 @@ export const pluginVirtualModuleGenerator = (
|
||||
for (const { name } of plugins) {
|
||||
const checkMode = mode === 'main' ? 'backend' : mode;
|
||||
// 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.blankLine();
|
||||
|
||||
@ -3,11 +3,18 @@ import { resolve, basename, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
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';
|
||||
|
||||
export default function (mode: 'backend' | 'preload' | 'renderer' | 'none'): PluginOption {
|
||||
export default function (
|
||||
mode: 'backend' | 'preload' | 'renderer' | 'none',
|
||||
): PluginOption {
|
||||
const pluginFilter = createFilter([
|
||||
'src/plugins/*/index.{js,ts}',
|
||||
'src/plugins/*',
|
||||
@ -100,14 +107,17 @@ export default function (mode: 'backend' | 'preload' | 'renderer' | 'none'): Plu
|
||||
objExpr.getProperty(propertyNames[index])?.remove();
|
||||
}
|
||||
|
||||
const stubObjExpr = src.addVariableStatement({
|
||||
isExported: true,
|
||||
declarationKind: VariableDeclarationKind.Const,
|
||||
declarations: [{
|
||||
name: 'pluginStub',
|
||||
initializer: (writer) => writer.write(objExpr!.getText()),
|
||||
}]
|
||||
})
|
||||
const stubObjExpr = src
|
||||
.addVariableStatement({
|
||||
isExported: true,
|
||||
declarationKind: VariableDeclarationKind.Const,
|
||||
declarations: [
|
||||
{
|
||||
name: 'pluginStub',
|
||||
initializer: (writer) => writer.write(objExpr.getText()),
|
||||
},
|
||||
],
|
||||
})
|
||||
.getDeclarations()[0]
|
||||
.getInitializer() as ObjectLiteralExpression;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user