mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 18:41:47 +00:00
Merge branch 'master' into master
This commit is contained in:
@ -61,8 +61,8 @@ export default createPlugin<
|
||||
];
|
||||
//Finds the playlist
|
||||
const playlist =
|
||||
document.querySelector('ytmusic-shelf-renderer') ??
|
||||
document.querySelector('ytmusic-playlist-shelf-renderer')!;
|
||||
document.querySelector('ytmusic-playlist-shelf-renderer') ??
|
||||
document.querySelector('ytmusic-shelf-renderer')!;
|
||||
// Adds an observer for every button, so it gets updated when one is clicked
|
||||
this.changeObserver?.disconnect();
|
||||
this.changeObserver = new MutationObserver(() => {
|
||||
@ -157,9 +157,9 @@ export default createPlugin<
|
||||
if (loader.children.length != 0) return;
|
||||
this.loadObserver?.disconnect();
|
||||
let playlistButtons: NodeListOf<HTMLElement> | undefined;
|
||||
const playlist = document.querySelector('ytmusic-shelf-renderer')
|
||||
? document.querySelector('ytmusic-shelf-renderer')
|
||||
: document.querySelector('ytmusic-playlist-shelf-renderer');
|
||||
const playlist =
|
||||
document.querySelector('ytmusic-playlist-shelf-renderer') ??
|
||||
document.querySelector('ytmusic-shelf-renderer');
|
||||
switch (id) {
|
||||
case 'allundislike':
|
||||
playlistButtons = playlist?.querySelectorAll(
|
||||
|
||||
246
src/plugins/auth-proxy-adapter/backend/index.ts
Normal file
246
src/plugins/auth-proxy-adapter/backend/index.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import net from 'net';
|
||||
|
||||
import { SocksClient, SocksClientOptions } from 'socks';
|
||||
|
||||
import is from 'electron-is';
|
||||
|
||||
import { createBackend, LoggerPrefix } from '@/utils';
|
||||
|
||||
import { BackendType } from './types';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
import { AuthProxyConfig, defaultAuthProxyConfig } from '../config';
|
||||
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
// Parsing the upstream authentication SOCK proxy URL
|
||||
const parseSocksUrl = (socksUrl: string) => {
|
||||
// Format: socks5://username:password@your_server_ip:port
|
||||
|
||||
const url = new URL(socksUrl);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port, 10),
|
||||
type: url.protocol === 'socks5:' ? 5 : 4,
|
||||
username: url.username,
|
||||
password: url.password,
|
||||
};
|
||||
};
|
||||
|
||||
export const backend = createBackend<BackendType, AuthProxyConfig>({
|
||||
async start(ctx: BackendContext<AuthProxyConfig>) {
|
||||
const pluginConfig = await ctx.getConfig();
|
||||
this.startServer(pluginConfig);
|
||||
},
|
||||
stop() {
|
||||
this.stopServer();
|
||||
},
|
||||
onConfigChange(config: AuthProxyConfig) {
|
||||
if (
|
||||
this.oldConfig?.hostname === config.hostname &&
|
||||
this.oldConfig?.port === config.port
|
||||
) {
|
||||
this.oldConfig = config;
|
||||
return;
|
||||
}
|
||||
this.stopServer();
|
||||
this.startServer(config);
|
||||
|
||||
this.oldConfig = config;
|
||||
},
|
||||
|
||||
// Custom
|
||||
// Start proxy server - SOCKS5
|
||||
startServer(serverConfig: AuthProxyConfig) {
|
||||
if (this.server) {
|
||||
this.stopServer();
|
||||
}
|
||||
|
||||
const { port, hostname } = serverConfig;
|
||||
// Upstream proxy from system settings
|
||||
const upstreamProxyUrl = config.get('options.proxy');
|
||||
// Create SOCKS proxy server
|
||||
const socksServer = net.createServer((socket) => {
|
||||
socket.once('data', (chunk) => {
|
||||
if (chunk[0] === 0x05) {
|
||||
// SOCKS5
|
||||
this.handleSocks5(socket, chunk, upstreamProxyUrl);
|
||||
} else {
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
console.error(LoggerPrefix, '[SOCKS] Socket error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for errors
|
||||
socksServer.on('error', (err) => {
|
||||
console.error(LoggerPrefix, '[SOCKS Server Error]', err.message);
|
||||
});
|
||||
|
||||
// Start server
|
||||
socksServer.listen(port, hostname, () => {
|
||||
console.log(LoggerPrefix, '===========================================');
|
||||
console.log(
|
||||
LoggerPrefix,
|
||||
`[Auth-Proxy-Adapter] Enable SOCKS proxy at socks5://${hostname}:${port}`,
|
||||
);
|
||||
console.log(
|
||||
LoggerPrefix,
|
||||
`[Auth-Proxy-Adapter] Using upstream proxy: ${upstreamProxyUrl}`,
|
||||
);
|
||||
console.log(LoggerPrefix, '===========================================');
|
||||
});
|
||||
|
||||
this.server = socksServer;
|
||||
},
|
||||
|
||||
// Handle SOCKS5 request
|
||||
handleSocks5(
|
||||
clientSocket: net.Socket,
|
||||
chunk: Buffer,
|
||||
upstreamProxyUrl: string,
|
||||
) {
|
||||
// Handshake phase
|
||||
const numMethods = chunk[1];
|
||||
const methods = chunk.subarray(2, 2 + numMethods);
|
||||
|
||||
// Check if client supports no authentication method (0x00)
|
||||
if (methods.includes(0x00)) {
|
||||
// Reply to client, choose no authentication method
|
||||
clientSocket.write(Buffer.from([0x05, 0x00]));
|
||||
|
||||
// Wait for client's connection request
|
||||
clientSocket.once('data', (data) => {
|
||||
this.processSocks5Request(clientSocket, data, upstreamProxyUrl);
|
||||
});
|
||||
} else {
|
||||
// Authentication methods not supported by the client
|
||||
clientSocket.write(Buffer.from([0x05, 0xff]));
|
||||
clientSocket.end();
|
||||
}
|
||||
},
|
||||
|
||||
// Handle SOCKS5 connection request
|
||||
processSocks5Request(
|
||||
clientSocket: net.Socket,
|
||||
data: Buffer,
|
||||
upstreamProxyUrl: string,
|
||||
) {
|
||||
// Parse target address and port
|
||||
let targetHost, targetPort;
|
||||
const cmd = data[1]; // Command: 0x01=CONNECT, 0x02=BIND, 0x03=UDP
|
||||
const atyp = data[3]; // Address type: 0x01=IPv4, 0x03=Domain, 0x04=IPv6
|
||||
|
||||
if (cmd !== 0x01) {
|
||||
// Currently only support CONNECT command
|
||||
clientSocket.write(
|
||||
Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (atyp === 0x01) {
|
||||
// IPv4
|
||||
targetHost = `${data[4]}.${data[5]}.${data[6]}.${data[7]}`;
|
||||
targetPort = data.readUInt16BE(8);
|
||||
} else if (atyp === 0x03) {
|
||||
// Domain
|
||||
const hostLen = data[4];
|
||||
targetHost = data.subarray(5, 5 + hostLen).toString();
|
||||
targetPort = data.readUInt16BE(5 + hostLen);
|
||||
} else if (atyp === 0x04) {
|
||||
// IPv6
|
||||
const ipv6Buffer = data.subarray(4, 20);
|
||||
targetHost = Array.from(new Array(8), (_, i) =>
|
||||
ipv6Buffer.readUInt16BE(i * 2).toString(16),
|
||||
).join(':');
|
||||
targetPort = data.readUInt16BE(20);
|
||||
}
|
||||
if (is.dev()) {
|
||||
console.debug(
|
||||
LoggerPrefix,
|
||||
`[SOCKS5] Request to connect to ${targetHost}:${targetPort}`,
|
||||
);
|
||||
}
|
||||
|
||||
const socksProxy = parseSocksUrl(upstreamProxyUrl);
|
||||
|
||||
if (!socksProxy) {
|
||||
// Failed to parse proxy URL
|
||||
clientSocket.write(
|
||||
Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const options: SocksClientOptions = {
|
||||
proxy: {
|
||||
host: socksProxy.host,
|
||||
port: socksProxy.port,
|
||||
type: socksProxy.type as 4 | 5,
|
||||
userId: socksProxy.username,
|
||||
password: socksProxy.password,
|
||||
},
|
||||
command: 'connect',
|
||||
destination: {
|
||||
host: targetHost || defaultAuthProxyConfig.hostname,
|
||||
port: targetPort || defaultAuthProxyConfig.port,
|
||||
},
|
||||
};
|
||||
SocksClient.createConnection(options)
|
||||
.then((info) => {
|
||||
const { socket: proxySocket } = info;
|
||||
|
||||
// Connection successful, send success response to client
|
||||
const responseBuffer = Buffer.from([
|
||||
0x05, // VER: SOCKS5
|
||||
0x00, // REP: Success
|
||||
0x00, // RSV: Reserved field
|
||||
0x01, // ATYP: IPv4
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // BND.ADDR: 0.0.0.0 (Bound address, usually not important)
|
||||
0,
|
||||
0, // BND.PORT: 0 (Bound port, usually not important)
|
||||
]);
|
||||
clientSocket.write(responseBuffer);
|
||||
|
||||
// Establish bidirectional data stream
|
||||
proxySocket.pipe(clientSocket);
|
||||
clientSocket.pipe(proxySocket);
|
||||
|
||||
proxySocket.on('error', (error) => {
|
||||
console.error(LoggerPrefix, '[SOCKS5] Proxy socket error:', error);
|
||||
if (clientSocket.writable) clientSocket.end();
|
||||
});
|
||||
|
||||
clientSocket.on('error', (error) => {
|
||||
console.error(LoggerPrefix, '[SOCKS5] Client socket error:', error);
|
||||
if (proxySocket.writable) proxySocket.end();
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(LoggerPrefix, '[SOCKS5] Connection error:', error);
|
||||
// Send failure response to client
|
||||
clientSocket.write(
|
||||
Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
clientSocket.end();
|
||||
});
|
||||
},
|
||||
|
||||
// Stop proxy server
|
||||
stopServer() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
21
src/plugins/auth-proxy-adapter/backend/types.ts
Normal file
21
src/plugins/auth-proxy-adapter/backend/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import net from 'net';
|
||||
|
||||
import type { AuthProxyConfig } from '../config';
|
||||
import type { Server } from 'http';
|
||||
|
||||
export type BackendType = {
|
||||
server?: Server | net.Server;
|
||||
oldConfig?: AuthProxyConfig;
|
||||
startServer: (serverConfig: AuthProxyConfig) => void;
|
||||
stopServer: () => void;
|
||||
handleSocks5: (
|
||||
clientSocket: net.Socket,
|
||||
chunk: Buffer,
|
||||
upstreamProxyUrl: string,
|
||||
) => void;
|
||||
processSocks5Request: (
|
||||
clientSocket: net.Socket,
|
||||
data: Buffer,
|
||||
upstreamProxyUrl: string,
|
||||
) => void;
|
||||
};
|
||||
11
src/plugins/auth-proxy-adapter/config.ts
Normal file
11
src/plugins/auth-proxy-adapter/config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface AuthProxyConfig {
|
||||
enabled: boolean;
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export const defaultAuthProxyConfig: AuthProxyConfig = {
|
||||
enabled: false,
|
||||
hostname: '127.0.0.1',
|
||||
port: 4545,
|
||||
};
|
||||
16
src/plugins/auth-proxy-adapter/index.ts
Normal file
16
src/plugins/auth-proxy-adapter/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { createPlugin } from '@/utils';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { defaultAuthProxyConfig } from './config';
|
||||
import { onMenu } from './menu';
|
||||
import { backend } from './backend';
|
||||
|
||||
export default createPlugin({
|
||||
name: () => t('plugins.auth-proxy-adapter.name'),
|
||||
description: () => t('plugins.auth-proxy-adapter.description'),
|
||||
restartNeeded: true,
|
||||
config: defaultAuthProxyConfig,
|
||||
addedVersion: '3.10.X',
|
||||
menu: onMenu,
|
||||
backend,
|
||||
});
|
||||
68
src/plugins/auth-proxy-adapter/menu.ts
Normal file
68
src/plugins/auth-proxy-adapter/menu.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
|
||||
import { type AuthProxyConfig, defaultAuthProxyConfig } from './config';
|
||||
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
export const onMenu = async ({
|
||||
getConfig,
|
||||
setConfig,
|
||||
window,
|
||||
}: MenuContext<AuthProxyConfig>): Promise<MenuTemplate> => {
|
||||
await getConfig();
|
||||
return [
|
||||
{
|
||||
label: t('plugins.auth-proxy-adapter.menu.hostname.label'),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
|
||||
const newHostname =
|
||||
(await prompt(
|
||||
{
|
||||
title: t('plugins.auth-proxy-adapter.prompt.hostname.title'),
|
||||
label: t('plugins.auth-proxy-adapter.prompt.hostname.label'),
|
||||
value: config.hostname,
|
||||
type: 'input',
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
)) ??
|
||||
config.hostname ??
|
||||
defaultAuthProxyConfig.hostname;
|
||||
|
||||
setConfig({ ...config, hostname: newHostname });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.auth-proxy-adapter.menu.port.label'),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
const config = await getConfig();
|
||||
|
||||
const newPort =
|
||||
(await prompt(
|
||||
{
|
||||
title: t('plugins.auth-proxy-adapter.prompt.port.title'),
|
||||
label: t('plugins.auth-proxy-adapter.prompt.port.label'),
|
||||
value: config.port,
|
||||
type: 'counter',
|
||||
counterOptions: { minimum: 0, maximum: 65535 },
|
||||
width: 380,
|
||||
...promptOptions(),
|
||||
},
|
||||
window,
|
||||
)) ??
|
||||
config.port ??
|
||||
defaultAuthProxyConfig.port;
|
||||
|
||||
setConfig({ ...config, port: newPort });
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -80,3 +80,8 @@ html {
|
||||
ytmusic-browse-response .ytmusic-responsive-list-item-renderer {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* fix fullscreen style */
|
||||
ytmusic-player[player-ui-state='FULLSCREEN'] {
|
||||
margin-top: calc(var(--menu-bar-height, 32px) * -1) !important;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DataConnection, Peer, PeerErrorType } from 'peerjs';
|
||||
import { DataConnection, Peer, PeerError, PeerErrorType } from 'peerjs';
|
||||
import delay from 'delay';
|
||||
|
||||
import type { Permission, Profile, VideoData } from './types';
|
||||
@ -14,6 +14,7 @@ export type ConnectionEventMap = {
|
||||
| { progress?: number; state?: number; index?: number }
|
||||
| undefined;
|
||||
PERMISSION: Permission | undefined;
|
||||
CONNECTION_CLOSED: null;
|
||||
};
|
||||
export type ConnectionEventUnion = {
|
||||
[Event in keyof ConnectionEventMap]: {
|
||||
@ -31,7 +32,7 @@ type PromiseUtil<T> = {
|
||||
|
||||
export type ConnectionListener = (
|
||||
event: ConnectionEventUnion,
|
||||
conn: DataConnection,
|
||||
conn: DataConnection | null,
|
||||
) => void;
|
||||
export type ConnectionMode = 'host' | 'guest' | 'disconnected';
|
||||
export class Connection {
|
||||
@ -44,7 +45,31 @@ export class Connection {
|
||||
private connectionListeners: ((connection?: DataConnection) => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
this.peer = new Peer({ debug: 0 });
|
||||
this.peer = new Peer({
|
||||
debug: 0,
|
||||
config: {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{
|
||||
urls: [
|
||||
'turn:eu-0.turn.peerjs.com:3478',
|
||||
'turn:us-0.turn.peerjs.com:3478',
|
||||
],
|
||||
username: 'peerjs',
|
||||
credential: 'peerjsp',
|
||||
},
|
||||
{
|
||||
urls: 'stun:freestun.net:3478',
|
||||
},
|
||||
{
|
||||
urls: 'turn:freestun.net:3478',
|
||||
username: 'free',
|
||||
credential: 'free',
|
||||
},
|
||||
],
|
||||
sdpSemantics: 'unified-plan',
|
||||
},
|
||||
});
|
||||
|
||||
this.waitOpen.promise = new Promise<string>((resolve, reject) => {
|
||||
this.waitOpen.resolve = resolve;
|
||||
@ -59,6 +84,19 @@ export class Connection {
|
||||
this._mode = 'host';
|
||||
await this.registerConnection(conn);
|
||||
});
|
||||
this.peer.on('close', () => {
|
||||
for (const listener of this.listeners) {
|
||||
listener({ type: 'CONNECTION_CLOSED', payload: null }, null);
|
||||
}
|
||||
this.listeners = [];
|
||||
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
this.connectionListeners = [];
|
||||
this.connections = {};
|
||||
|
||||
this.peer.disconnect();
|
||||
this.peer.destroy();
|
||||
});
|
||||
this.peer.on('error', async (err) => {
|
||||
if (err.type === PeerErrorType.Network) {
|
||||
// retrying after 10 seconds
|
||||
@ -70,11 +108,12 @@ export class Connection {
|
||||
//ignored
|
||||
}
|
||||
}
|
||||
this._mode = 'disconnected';
|
||||
|
||||
this.waitOpen.reject(err);
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
console.error(err);
|
||||
this.disconnect();
|
||||
|
||||
console.trace(err);
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,7 +135,18 @@ export class Connection {
|
||||
if (this._mode === 'disconnected') throw new Error('Already disconnected');
|
||||
|
||||
this._mode = 'disconnected';
|
||||
this.getConnections().forEach((conn) =>
|
||||
conn.close({
|
||||
flush: true,
|
||||
}),
|
||||
);
|
||||
this.connections = {};
|
||||
this.connectionListeners = [];
|
||||
for (const listener of this.listeners) {
|
||||
listener({ type: 'CONNECTION_CLOSED', payload: null }, null);
|
||||
}
|
||||
this.listeners = [];
|
||||
this.peer.disconnect();
|
||||
this.peer.destroy();
|
||||
}
|
||||
|
||||
@ -123,7 +173,9 @@ export class Connection {
|
||||
}
|
||||
|
||||
public on(listener: ConnectionListener) {
|
||||
this.listeners.push(listener);
|
||||
if (!this.listeners.includes(listener)) {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public onConnections(listener: (connections?: DataConnection) => void) {
|
||||
@ -134,10 +186,10 @@ export class Connection {
|
||||
private async registerConnection(conn: DataConnection) {
|
||||
return new Promise<DataConnection>((resolve, reject) => {
|
||||
this.peer.once('error', (err) => {
|
||||
this._mode = 'disconnected';
|
||||
|
||||
reject(err);
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
|
||||
this.disconnect();
|
||||
});
|
||||
|
||||
conn.on('open', () => {
|
||||
@ -163,11 +215,28 @@ export class Connection {
|
||||
});
|
||||
});
|
||||
|
||||
const onClose = (err?: Error) => {
|
||||
if (err) reject(err);
|
||||
const onClose = (
|
||||
err?: PeerError<
|
||||
| 'not-open-yet'
|
||||
| 'message-too-big'
|
||||
| 'negotiation-failed'
|
||||
| 'connection-closed'
|
||||
>,
|
||||
) => {
|
||||
if (conn.open) {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
delete this.connections[conn.connectionId];
|
||||
this.connectionListeners.forEach((listener) => listener(conn));
|
||||
|
||||
if (err) {
|
||||
if (err.type === 'connection-closed') {
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
}
|
||||
reject(err);
|
||||
} else {
|
||||
this.connectionListeners.forEach((listener) => listener(conn));
|
||||
}
|
||||
};
|
||||
conn.on('error', onClose);
|
||||
conn.on('close', onClose);
|
||||
|
||||
@ -21,6 +21,8 @@ import { createSettingPopup } from './ui/setting';
|
||||
import settingHTML from './templates/setting.html?raw';
|
||||
import style from './style.css?inline';
|
||||
|
||||
import { waitForElement } from '@/utils/wait-for-element';
|
||||
|
||||
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { VideoDataChanged } from '@/types/video-data-changed';
|
||||
@ -123,10 +125,12 @@ export default createPlugin<
|
||||
if (this.connection?.mode === 'host') {
|
||||
const videoList: VideoData[] =
|
||||
this.queue?.flatItems.map(
|
||||
(it) =>
|
||||
(it, index) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.connection!.id,
|
||||
ownerId:
|
||||
this.queue?.videoList[index]?.ownerId ??
|
||||
this.connection!.id,
|
||||
}) satisfies VideoData,
|
||||
) ?? [];
|
||||
|
||||
@ -163,6 +167,17 @@ export default createPlugin<
|
||||
if (!wait) return false;
|
||||
|
||||
if (!this.me) this.me = getDefaultProfile(this.connection.id);
|
||||
|
||||
this.profiles = {};
|
||||
this.putProfile(this.connection.id, {
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
|
||||
this.queue?.setOwner({
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
const rawItems =
|
||||
this.queue?.flatItems?.map(
|
||||
(it) =>
|
||||
@ -171,16 +186,11 @@ export default createPlugin<
|
||||
ownerId: this.connection!.id,
|
||||
}) satisfies VideoData,
|
||||
) ?? [];
|
||||
this.queue?.setOwner({
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
this.queue?.setVideoList(rawItems, false);
|
||||
this.queue?.syncQueueOwner();
|
||||
this.queue?.initQueue();
|
||||
this.queue?.injection();
|
||||
|
||||
this.profiles = {};
|
||||
this.connection.onConnections((connection) => {
|
||||
if (!connection) {
|
||||
this.api?.toastService?.show(
|
||||
@ -199,30 +209,43 @@ export default createPlugin<
|
||||
this.putProfile(connection.peer, undefined);
|
||||
}
|
||||
});
|
||||
this.putProfile(this.connection.id, {
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
|
||||
const listener = async (
|
||||
event: ConnectionEventUnion,
|
||||
conn?: DataConnection,
|
||||
conn?: DataConnection | null,
|
||||
) => {
|
||||
this.ignoreChange = true;
|
||||
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
if (conn && this.permission === 'host-only') return;
|
||||
if (conn && this.permission === 'host-only') {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.queue?.addVideos(
|
||||
event.payload.videoList,
|
||||
event.payload.index,
|
||||
const videoList: VideoData[] = event.payload.videoList.map(
|
||||
(it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? conn?.peer ?? this.connection!.id,
|
||||
}),
|
||||
);
|
||||
await this.connection?.broadcast('ADD_SONGS', event.payload);
|
||||
|
||||
await this.queue?.addVideos(videoList, event.payload.index);
|
||||
await this.connection?.broadcast('ADD_SONGS', {
|
||||
...event.payload,
|
||||
videoList,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
if (conn && this.permission === 'host-only') return;
|
||||
if (conn && this.permission === 'host-only') {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue?.removeVideo(event.payload.index);
|
||||
await this.connection?.broadcast('REMOVE_SONG', event.payload);
|
||||
@ -309,6 +332,10 @@ export default createPlugin<
|
||||
|
||||
break;
|
||||
}
|
||||
case 'CONNECTION_CLOSED': {
|
||||
this.queue?.off(listener);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Music Together [Host]: Unknown Event', event);
|
||||
break;
|
||||
@ -357,14 +384,53 @@ export default createPlugin<
|
||||
});
|
||||
|
||||
let resolveIgnore: number | null = null;
|
||||
const queueListener = async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
await this.connection?.broadcast('ADD_SONGS', {
|
||||
...event.payload,
|
||||
videoList: event.payload.videoList.map((it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.connection!.id,
|
||||
})),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
await this.connection?.broadcast('REMOVE_SONG', event.payload);
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
await this.connection?.broadcast('MOVE_SONG', event.payload);
|
||||
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);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof resolveIgnore === 'number') clearTimeout(resolveIgnore);
|
||||
resolveIgnore = window.setTimeout(() => {
|
||||
this.ignoreChange = false;
|
||||
}, 16); // wait 1 frame
|
||||
};
|
||||
const listener = async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
await this.queue?.addVideos(
|
||||
event.payload.videoList,
|
||||
event.payload.index,
|
||||
const videoList: VideoData[] = event.payload.videoList.map(
|
||||
(it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.connection!.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.queue?.addVideos(videoList, event.payload.index);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
@ -446,6 +512,10 @@ export default createPlugin<
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'CONNECTION_CLOSED': {
|
||||
this.queue?.off(queueListener);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Music Together [Guest]: Unknown Event', event);
|
||||
break;
|
||||
@ -459,37 +529,7 @@ export default createPlugin<
|
||||
};
|
||||
|
||||
this.connection.on(listener);
|
||||
this.queue?.on(async (event: ConnectionEventUnion) => {
|
||||
this.ignoreChange = true;
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
await this.connection?.broadcast('ADD_SONGS', event.payload);
|
||||
await this.connection?.broadcast('SYNC_QUEUE', undefined);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
await this.connection?.broadcast('REMOVE_SONG', event.payload);
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
await this.connection?.broadcast('MOVE_SONG', event.payload);
|
||||
await this.connection?.broadcast('SYNC_QUEUE', undefined);
|
||||
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);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof resolveIgnore === 'number') clearTimeout(resolveIgnore);
|
||||
resolveIgnore = window.setTimeout(() => {
|
||||
this.ignoreChange = false;
|
||||
}, 16); // wait 1 frame
|
||||
});
|
||||
this.queue?.on(queueListener);
|
||||
|
||||
if (!this.me) this.me = getDefaultProfile(this.connection.id);
|
||||
this.queue?.injection();
|
||||
@ -595,19 +635,22 @@ export default createPlugin<
|
||||
this.elements.spinner.setAttribute('hidden', '');
|
||||
},
|
||||
|
||||
initMyProfile() {
|
||||
const accountButton = document.querySelector<
|
||||
HTMLElement & {
|
||||
onButtonTap: () => void;
|
||||
}
|
||||
>('ytmusic-settings-button');
|
||||
async initMyProfile() {
|
||||
const accountButton = await waitForElement<HTMLElement>(
|
||||
'#right-content > ytmusic-settings-button *:where(tp-yt-paper-icon-button,yt-icon-button,.ytmusic-settings-button)',
|
||||
{
|
||||
maxRetry: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
accountButton?.onButtonTap();
|
||||
setTimeout(() => {
|
||||
accountButton?.onButtonTap();
|
||||
const renderer = document.querySelector<
|
||||
HTMLElement & { data: unknown }
|
||||
>('ytd-active-account-header-renderer');
|
||||
accountButton?.click();
|
||||
setTimeout(async () => {
|
||||
const renderer = await waitForElement<HTMLElement & { data: unknown }>(
|
||||
'ytd-active-account-header-renderer',
|
||||
{
|
||||
maxRetry: 10000,
|
||||
},
|
||||
);
|
||||
if (!accountButton || !renderer) {
|
||||
console.warn('Music Together: Cannot find account');
|
||||
this.me = getDefaultProfile(this.connection?.id ?? '');
|
||||
@ -628,13 +671,14 @@ export default createPlugin<
|
||||
this.popups.guest.setProfile(this.me.thumbnail);
|
||||
this.popups.setting.setProfile(this.me.thumbnail);
|
||||
}
|
||||
accountButton?.click(); // close menu
|
||||
}, 0);
|
||||
},
|
||||
/* hooks */
|
||||
|
||||
start({ ipc }) {
|
||||
this.ipc = ipc;
|
||||
this.showPrompt = async (title: string, label: string) =>
|
||||
this.showPrompt = (title: string, label: string) =>
|
||||
ipc.invoke('music-together:prompt', title, label) as Promise<string>;
|
||||
this.api = document.querySelector<AppElement>('ytmusic-app');
|
||||
|
||||
|
||||
@ -3,8 +3,9 @@ import { mapQueueItem } from './utils';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { getDefaultProfile, type Profile, type VideoData } from '../types';
|
||||
|
||||
import type { ConnectionEventUnion } from '@/plugins/music-together/connection';
|
||||
import type { Profile, VideoData } from '../types';
|
||||
import type { QueueItem } from '@/types/datahost-get-state';
|
||||
import type { QueueElement, Store } from '@/types/queue';
|
||||
|
||||
@ -177,21 +178,46 @@ export class Queue {
|
||||
if (!items) return false;
|
||||
|
||||
this.internalDispatch = true;
|
||||
this._videoList.push(...videos);
|
||||
this._videoList = this._videoList.map(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it.videoId,
|
||||
ownerId: it.ownerId ?? this.owner!.id,
|
||||
}) satisfies VideoData,
|
||||
);
|
||||
|
||||
const state = this.queue.queue.store.store.getState();
|
||||
|
||||
this.queue?.dispatch({
|
||||
type: 'ADD_ITEMS',
|
||||
payload: {
|
||||
nextQueueItemId:
|
||||
this.queue.queue.store.store.getState().queue.nextQueueItemId,
|
||||
nextQueueItemId: state.queue.nextQueueItemId,
|
||||
index:
|
||||
index ??
|
||||
this.queue.queue.store.store.getState().queue.items.length ??
|
||||
(state.queue.items.length ? state.queue.items.length - 1 : null) ??
|
||||
0,
|
||||
items,
|
||||
shuffleEnabled: false,
|
||||
shouldAssignIds: true,
|
||||
},
|
||||
});
|
||||
|
||||
const insertedItem = this._videoList[index ?? this._videoList.length];
|
||||
if (
|
||||
!insertedItem ||
|
||||
(insertedItem.videoId !== videos[0].videoId &&
|
||||
insertedItem.ownerId !== videos[0].ownerId)
|
||||
) {
|
||||
this._videoList.splice(
|
||||
index ?? this._videoList.length,
|
||||
0,
|
||||
...videos.map((it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.owner?.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
this.initQueue();
|
||||
@ -252,7 +278,9 @@ export class Queue {
|
||||
}
|
||||
|
||||
on(listener: QueueEventListener) {
|
||||
this.listeners.push(listener);
|
||||
if (!this.listeners.includes(listener)) {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
off(listener: QueueEventListener) {
|
||||
@ -302,9 +330,15 @@ export class Queue {
|
||||
}
|
||||
).items,
|
||||
);
|
||||
const index = this._videoList.length + videoList.length - 1;
|
||||
const index = this._videoList.length;
|
||||
|
||||
if (videoList.length > 0) {
|
||||
this._videoList = [
|
||||
...videoList.map((it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.owner?.id,
|
||||
})),
|
||||
];
|
||||
this.broadcast({
|
||||
// play
|
||||
type: 'ADD_SONGS',
|
||||
@ -328,23 +362,45 @@ export class Queue {
|
||||
}
|
||||
).items.length === 1
|
||||
) {
|
||||
const videoList = mapQueueItem(
|
||||
(it) =>
|
||||
({
|
||||
videoId: it!.videoId,
|
||||
ownerId: this.owner!.id,
|
||||
}) satisfies VideoData,
|
||||
(
|
||||
event.payload! as {
|
||||
items: QueueItem[];
|
||||
}
|
||||
).items,
|
||||
);
|
||||
this._videoList.splice(
|
||||
event.payload && Object.hasOwn(event.payload, 'index')
|
||||
? (
|
||||
event.payload as {
|
||||
index: number;
|
||||
}
|
||||
).index
|
||||
: this._videoList.length,
|
||||
0,
|
||||
...videoList.map((it) => ({
|
||||
...it,
|
||||
ownerId: it.ownerId ?? this.owner?.id,
|
||||
})),
|
||||
);
|
||||
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! as {
|
||||
items: QueueItem[];
|
||||
}
|
||||
).items,
|
||||
),
|
||||
index:
|
||||
event.payload && Object.hasOwn(event.payload, 'index')
|
||||
? (
|
||||
event.payload as {
|
||||
index: number;
|
||||
}
|
||||
).index
|
||||
: undefined,
|
||||
videoList,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -478,14 +534,16 @@ export class Queue {
|
||||
|
||||
allQueue.forEach((queue) => {
|
||||
const list = Array.from(
|
||||
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
|
||||
queue?.querySelectorAll<HTMLElement>(
|
||||
'#contents > ytmusic-player-queue-item,#contents > ytmusic-playlist-panel-video-wrapper-renderer > #primary-renderer > ytmusic-player-queue-item',
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
list.forEach((item, index: number | undefined) => {
|
||||
if (typeof index !== 'number') return;
|
||||
|
||||
const id = this._videoList[index]?.ownerId;
|
||||
const data = this.getProfile(id);
|
||||
let data = this.getProfile(id);
|
||||
|
||||
const profile =
|
||||
item.querySelector<HTMLImageElement>('.music-together-owner') ??
|
||||
@ -501,6 +559,10 @@ export class Queue {
|
||||
name.textContent =
|
||||
data?.name ?? t('plugins.music-together.internal.unknown-user');
|
||||
|
||||
if (!data?.name && !data?.handleId) {
|
||||
data = getDefaultProfile(data?.id ?? '');
|
||||
}
|
||||
|
||||
if (data) {
|
||||
profile.dataset.thumbnail = data.thumbnail ?? '';
|
||||
profile.dataset.name = data.name ?? '';
|
||||
|
||||
Reference in New Issue
Block a user