mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-09 01:31:46 +00:00
refactor(music-together): migrate music-together plugin (vanilla to solid-js)
This commit is contained in:
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@ -14,13 +14,13 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@malept/flatpak-bundler':
|
||||
hash: c787371eeb2af011ea934e8818a0dad6d7dcb2df31bbb1686babc7231af0183c
|
||||
hash: vli4xtu6rndz3xtsscixhngsxa
|
||||
path: patches/@malept__flatpak-bundler.patch
|
||||
app-builder-lib@26.0.6:
|
||||
hash: 1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f
|
||||
hash: zcnm2qnjaggm2keyecnhiglkke
|
||||
path: patches/app-builder-lib@26.0.6.patch
|
||||
vudio@2.1.1:
|
||||
hash: 0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d
|
||||
hash: 7iux5msqpgl3octdmwy4uspwoe
|
||||
path: patches/vudio@2.1.1.patch
|
||||
|
||||
importers:
|
||||
@ -191,7 +191,7 @@ importers:
|
||||
version: 25.0.1
|
||||
vudio:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1(patch_hash=0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d)
|
||||
version: 2.1.1(patch_hash=7iux5msqpgl3octdmwy4uspwoe)
|
||||
x11:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@ -5365,7 +5365,7 @@ snapshots:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
'@malept/flatpak-bundler@0.4.0(patch_hash=c787371eeb2af011ea934e8818a0dad6d7dcb2df31bbb1686babc7231af0183c)':
|
||||
'@malept/flatpak-bundler@0.4.0(patch_hash=vli4xtu6rndz3xtsscixhngsxa)':
|
||||
dependencies:
|
||||
debug: 4.4.0
|
||||
fs-extra: 9.1.0
|
||||
@ -5832,7 +5832,7 @@ snapshots:
|
||||
|
||||
app-builder-bin@5.0.0-alpha.12: {}
|
||||
|
||||
app-builder-lib@26.0.6(patch_hash=1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6):
|
||||
app-builder-lib@26.0.6(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6):
|
||||
dependencies:
|
||||
'@develar/schema-utils': 2.6.5
|
||||
'@electron/asar': 3.2.18
|
||||
@ -5841,7 +5841,7 @@ snapshots:
|
||||
'@electron/osx-sign': 1.3.1
|
||||
'@electron/rebuild': 3.7.0
|
||||
'@electron/universal': 2.0.1
|
||||
'@malept/flatpak-bundler': 0.4.0(patch_hash=c787371eeb2af011ea934e8818a0dad6d7dcb2df31bbb1686babc7231af0183c)
|
||||
'@malept/flatpak-bundler': 0.4.0(patch_hash=vli4xtu6rndz3xtsscixhngsxa)
|
||||
'@types/fs-extra': 9.0.13
|
||||
async-exit-hook: 2.0.1
|
||||
builder-util: 26.0.4
|
||||
@ -6464,7 +6464,7 @@ snapshots:
|
||||
|
||||
dmg-builder@26.0.6(electron-builder-squirrel-windows@26.0.6):
|
||||
dependencies:
|
||||
app-builder-lib: 26.0.6(patch_hash=1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6)
|
||||
app-builder-lib: 26.0.6(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6)
|
||||
builder-util: 26.0.4
|
||||
builder-util-runtime: 9.3.1
|
||||
fs-extra: 10.1.0
|
||||
@ -6541,7 +6541,7 @@ snapshots:
|
||||
|
||||
electron-builder-squirrel-windows@26.0.6(dmg-builder@26.0.6):
|
||||
dependencies:
|
||||
app-builder-lib: 26.0.6(patch_hash=1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6)
|
||||
app-builder-lib: 26.0.6(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6)
|
||||
builder-util: 26.0.4
|
||||
electron-winstaller: 5.4.0
|
||||
transitivePeerDependencies:
|
||||
@ -6551,7 +6551,7 @@ snapshots:
|
||||
|
||||
electron-builder@26.0.6(electron-builder-squirrel-windows@26.0.6):
|
||||
dependencies:
|
||||
app-builder-lib: 26.0.6(patch_hash=1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6)
|
||||
app-builder-lib: 26.0.6(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6)
|
||||
builder-util: 26.0.4
|
||||
builder-util-runtime: 9.3.1
|
||||
chalk: 4.1.2
|
||||
@ -9132,7 +9132,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 6.1.0(@types/node@22.13.4)(yaml@2.7.0)
|
||||
|
||||
vudio@2.1.1(patch_hash=0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d): {}
|
||||
vudio@2.1.1(patch_hash=7iux5msqpgl3octdmwy4uspwoe): {}
|
||||
|
||||
wcwidth@1.0.1:
|
||||
dependencies:
|
||||
|
||||
@ -541,7 +541,7 @@
|
||||
"menu": {
|
||||
"click-to-copy-id": "호스트 아이디 복사",
|
||||
"close": "Music Together 닫기",
|
||||
"connected-users": "연결된 사용자",
|
||||
"connected-users": "연결된 사용자: {{count}}명",
|
||||
"disconnect": "Music Together 연결 끊기",
|
||||
"empty-user": "연결된 사용자 없음",
|
||||
"host": "Music Together 호스트",
|
||||
|
||||
@ -720,7 +720,7 @@ export const register = (
|
||||
|
||||
app.openapi(routes.addSongToQueue, (ctx) => {
|
||||
const { videoId, insertPosition } = ctx.req.valid('json');
|
||||
controller.addSongToQueue(videoId, insertPosition);
|
||||
controller.addSongToQueue(videoId, { queueInsertPosition: insertPosition });
|
||||
|
||||
ctx.status(204);
|
||||
return ctx.body(null);
|
||||
|
||||
117
src/plugins/music-together/api/guest.ts
Normal file
117
src/plugins/music-together/api/guest.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { setQueue } from '../store/queue';
|
||||
|
||||
import { ConnectionEventUnion, MusicTogetherConfig, VideoData } from '../types';
|
||||
import { setStatus } from '../store/status';
|
||||
import { IPC } from '../constants';
|
||||
import { Connection } from '../connection';
|
||||
|
||||
import type { AppElement } from '@/types/queue';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
|
||||
type BuildListenerOptions = {
|
||||
ipc: RendererContext<MusicTogetherConfig>['ipc'];
|
||||
app: AppElement;
|
||||
};
|
||||
export const Guest = {
|
||||
buildListener: (_: Connection, { ipc, app }: BuildListenerOptions) => {
|
||||
const listener = async (event: ConnectionEventUnion) => {
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
await ipc.invoke(
|
||||
IPC.addSongToQueue,
|
||||
event.payload.videoList.map((v) => v.videoId),
|
||||
{
|
||||
index: event.payload.index,
|
||||
},
|
||||
);
|
||||
|
||||
setQueue('queue', (queue) => {
|
||||
const result: VideoData[] = [...queue];
|
||||
|
||||
if (event.payload.index) {
|
||||
result.splice(event.payload.index, 0, ...event.payload.videoList);
|
||||
} else {
|
||||
result.push(...event.payload.videoList);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
await ipc.invoke(IPC.removeSongFromQueue, event.payload.index);
|
||||
|
||||
setQueue('queue', (queue) => {
|
||||
const result: VideoData[] = [...queue];
|
||||
result.splice(event.payload.index, 1);
|
||||
return result;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
await ipc.invoke(
|
||||
IPC.moveSongInQueue,
|
||||
event.payload.fromIndex,
|
||||
event.payload.toIndex,
|
||||
);
|
||||
|
||||
setQueue('queue', (queue) => {
|
||||
const result: VideoData[] = [...queue];
|
||||
const [removed] = result.splice(event.payload.fromIndex, 1);
|
||||
result.splice(event.payload.toIndex, 0, removed);
|
||||
return result;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'IDENTIFY': {
|
||||
console.warn('Music Together [Guest]: Not allowed Event', event);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_USER': {
|
||||
setStatus('users', event.payload?.users ?? []);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'PERMISSION': {
|
||||
const permission = event.payload;
|
||||
if (!permission) break;
|
||||
|
||||
setStatus('permission', permission);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_QUEUE': {
|
||||
await ipc.invoke(IPC.clearQueue);
|
||||
await ipc.invoke(
|
||||
IPC.addSongToQueue,
|
||||
event.payload?.videoList.map((v) => v.videoId),
|
||||
{
|
||||
queueInsertPosition: 'INSERT_AT_END',
|
||||
},
|
||||
);
|
||||
setQueue('queue', event.payload?.videoList ?? []);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_PROGRESS': {
|
||||
if (typeof event.payload?.progress === 'number') {
|
||||
app.playerApi?.seekTo(event.payload.progress);
|
||||
}
|
||||
if (app.playerApi?.getPlayerState() !== event.payload?.state) {
|
||||
if (event.payload?.state === 2) app.playerApi?.pauseVideo();
|
||||
if (event.payload?.state === 1) app.playerApi?.playVideo();
|
||||
}
|
||||
if (typeof event.payload?.index === 'number') {
|
||||
await ipc.invoke(IPC.setQueueIndex, event.payload.index);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Music Together [Host]: Unknown Event', event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return listener;
|
||||
},
|
||||
};
|
||||
140
src/plugins/music-together/api/host.ts
Normal file
140
src/plugins/music-together/api/host.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { DataConnection } from 'peerjs';
|
||||
|
||||
import { RendererContext } from '@/types/contexts';
|
||||
|
||||
import { queue } from '@/plugins/music-together/store/queue';
|
||||
|
||||
import { AppElement } from '@/types/queue';
|
||||
|
||||
import { ConnectionEventUnion, MusicTogetherConfig } from '../types';
|
||||
import { setStatus, status } from '../store/status';
|
||||
import { IPC } from '../constants';
|
||||
import { Connection } from '../connection';
|
||||
|
||||
type BuildListenerOptions = {
|
||||
ipc: RendererContext<MusicTogetherConfig>['ipc'];
|
||||
app: AppElement;
|
||||
};
|
||||
export const Host = {
|
||||
buildListener: (conn: Connection, { ipc, app }: BuildListenerOptions) => {
|
||||
const listener = async (
|
||||
event: ConnectionEventUnion,
|
||||
dataConnection?: DataConnection,
|
||||
) => {
|
||||
switch (event.type) {
|
||||
case 'ADD_SONGS': {
|
||||
if (dataConnection && status.permission === 'host-only') return;
|
||||
|
||||
await ipc.invoke(
|
||||
IPC.addSongToQueue,
|
||||
event.payload.videoList.map((v) => v.videoId),
|
||||
{
|
||||
index: event.payload.index,
|
||||
},
|
||||
);
|
||||
console.log('ADD_SONGS', event);
|
||||
await conn?.broadcast(event.type, event.payload);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
if (dataConnection && status.permission === 'host-only') return;
|
||||
|
||||
await ipc.invoke(IPC.removeSongFromQueue, event.payload.index);
|
||||
await conn?.broadcast(event.type, event.payload);
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
if (dataConnection && status.permission === 'host-only') {
|
||||
// await conn.broadcast('SYNC_QUEUE', {
|
||||
// videoList: queue?.videoList ?? [],
|
||||
// });
|
||||
break;
|
||||
}
|
||||
|
||||
await ipc.invoke(
|
||||
IPC.moveSongInQueue,
|
||||
event.payload.fromIndex,
|
||||
event.payload.toIndex,
|
||||
);
|
||||
await conn?.broadcast(event.type, event.payload);
|
||||
break;
|
||||
}
|
||||
case 'IDENTIFY': {
|
||||
const newUser = event.payload?.user;
|
||||
if (!newUser) return;
|
||||
|
||||
// api?.toastService?.show(
|
||||
// t('plugins.music-together.toast.user-connected', {
|
||||
// name: event.payload.profile.name,
|
||||
// }),
|
||||
// );
|
||||
|
||||
setStatus('users', (users) => [...users, newUser]);
|
||||
await conn?.broadcast('SYNC_USER', {
|
||||
users: status.users,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'SYNC_USER': {
|
||||
await conn?.broadcast('SYNC_USER', {
|
||||
users: status.users,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 'PERMISSION': {
|
||||
await conn?.broadcast('PERMISSION', status.permission);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_QUEUE': {
|
||||
await conn?.broadcast('SYNC_QUEUE', {
|
||||
videoList: queue.queue,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'SYNC_PROGRESS': {
|
||||
let permissionLevel = 0;
|
||||
if (status.permission === 'all') permissionLevel = 2;
|
||||
if (status.permission === 'playlist') permissionLevel = 1;
|
||||
if (status.permission === 'host-only') permissionLevel = 0;
|
||||
if (!conn) permissionLevel = 3;
|
||||
|
||||
if (permissionLevel >= 2) {
|
||||
if (typeof event.payload?.progress === 'number') {
|
||||
const currentTime = app.playerApi?.getCurrentTime() ?? 0;
|
||||
const offset = Math.abs(event.payload.progress - currentTime);
|
||||
if (offset > 3)
|
||||
app.playerApi?.seekTo(event.payload.progress + offset);
|
||||
}
|
||||
if (app.playerApi?.getPlayerState() !== event.payload?.state) {
|
||||
if (event.payload?.state === 2) app.playerApi?.pauseVideo();
|
||||
if (event.payload?.state === 1) app.playerApi?.playVideo();
|
||||
}
|
||||
}
|
||||
if (permissionLevel >= 1) {
|
||||
if (typeof event.payload?.index === 'number') {
|
||||
await ipc.invoke(IPC.setQueueIndex, event.payload.index);
|
||||
}
|
||||
}
|
||||
|
||||
await conn?.broadcast('SYNC_PROGRESS', event.payload);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Music Together [Host]: Unknown Event', event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.after) {
|
||||
const now = event.after.shift();
|
||||
if (now) {
|
||||
now.after = event.after;
|
||||
await listener(now, dataConnection);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return listener;
|
||||
},
|
||||
};
|
||||
0
src/plugins/music-together/api/queue.ts
Normal file
0
src/plugins/music-together/api/queue.ts
Normal file
53
src/plugins/music-together/backend.ts
Normal file
53
src/plugins/music-together/backend.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { MusicTogetherConfig } from './types';
|
||||
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
|
||||
import getSongControls from '@/providers/song-controls';
|
||||
|
||||
import { IPC } from './constants';
|
||||
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
export const onMainLoad = ({
|
||||
ipc,
|
||||
window,
|
||||
}: BackendContext<MusicTogetherConfig>) => {
|
||||
const controller = getSongControls(window);
|
||||
|
||||
ipc.handle(IPC.prompt, async (title: string, label: string) =>
|
||||
prompt({
|
||||
title,
|
||||
label,
|
||||
type: 'input',
|
||||
...promptOptions(),
|
||||
}),
|
||||
);
|
||||
|
||||
ipc.handle(IPC.play, () => controller.play());
|
||||
ipc.handle(IPC.pause, () => controller.pause());
|
||||
ipc.handle(IPC.previous, () => controller.previous());
|
||||
ipc.handle(IPC.next, () => controller.next());
|
||||
ipc.handle(IPC.seekTo, (seconds: number) => controller.seekTo(seconds));
|
||||
ipc.handle(
|
||||
IPC.addSongToQueue,
|
||||
(
|
||||
ids: string | string[],
|
||||
options: {
|
||||
queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO';
|
||||
index?: number;
|
||||
},
|
||||
) => controller.addSongToQueue(ids, options),
|
||||
);
|
||||
ipc.handle(IPC.removeSongFromQueue, (index: number) =>
|
||||
controller.removeSongFromQueue(index),
|
||||
);
|
||||
ipc.handle(IPC.moveSongInQueue, (fromIndex: number, toIndex: number) =>
|
||||
controller.moveSongInQueue(fromIndex, toIndex),
|
||||
);
|
||||
ipc.handle(IPC.clearQueue, () => controller.clearQueue());
|
||||
ipc.handle(IPC.setQueueIndex, (index: number) =>
|
||||
controller.setQueueIndex(index),
|
||||
);
|
||||
};
|
||||
@ -1,26 +1,6 @@
|
||||
import { DataConnection, Peer } from 'peerjs';
|
||||
|
||||
import type { Permission, Profile, VideoData } from './types';
|
||||
|
||||
export type ConnectionEventMap = {
|
||||
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;
|
||||
PERMISSION: Permission | undefined;
|
||||
};
|
||||
export type ConnectionEventUnion = {
|
||||
[Event in keyof ConnectionEventMap]: {
|
||||
type: Event;
|
||||
payload: ConnectionEventMap[Event];
|
||||
after?: ConnectionEventUnion[];
|
||||
};
|
||||
}[keyof ConnectionEventMap];
|
||||
import { ConnectedState, ConnectionEventMap, ConnectionEventUnion } from './types';
|
||||
|
||||
type PromiseUtil<T> = {
|
||||
promise: Promise<T>;
|
||||
@ -32,10 +12,10 @@ export type ConnectionListener = (
|
||||
event: ConnectionEventUnion,
|
||||
conn: DataConnection,
|
||||
) => void;
|
||||
export type ConnectionMode = 'host' | 'guest' | 'disconnected';
|
||||
|
||||
export class Connection {
|
||||
private peer: Peer;
|
||||
private _mode: ConnectionMode = 'disconnected';
|
||||
private _state: ConnectedState = 'disconnected';
|
||||
private connections: Record<string, DataConnection> = {};
|
||||
|
||||
private waitOpen: PromiseUtil<string> = {} as PromiseUtil<string>;
|
||||
@ -51,15 +31,15 @@ export class Connection {
|
||||
});
|
||||
|
||||
this.peer.on('open', (id) => {
|
||||
this._mode = 'host';
|
||||
this._state = 'connecting';
|
||||
this.waitOpen.resolve(id);
|
||||
});
|
||||
this.peer.on('connection', (conn) => {
|
||||
this._mode = 'host';
|
||||
this._state = 'host';
|
||||
this.registerConnection(conn);
|
||||
});
|
||||
this.peer.on('error', (err) => {
|
||||
this._mode = 'disconnected';
|
||||
this._state = 'disconnected';
|
||||
|
||||
this.waitOpen.reject(err);
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
@ -73,16 +53,16 @@ export class Connection {
|
||||
}
|
||||
|
||||
async connect(id: string) {
|
||||
this._mode = 'guest';
|
||||
this._state = 'guest';
|
||||
const conn = this.peer.connect(id);
|
||||
await this.registerConnection(conn);
|
||||
return conn;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this._mode === 'disconnected') throw new Error('Already disconnected');
|
||||
if (this._state === 'disconnected') throw new Error('Already disconnected');
|
||||
|
||||
this._mode = 'disconnected';
|
||||
this._state = 'disconnected';
|
||||
this.connections = {};
|
||||
this.peer.destroy();
|
||||
}
|
||||
@ -92,8 +72,8 @@ export class Connection {
|
||||
return this.peer.id;
|
||||
}
|
||||
|
||||
public get mode() {
|
||||
return this._mode;
|
||||
public get state() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public getConnections() {
|
||||
@ -121,7 +101,7 @@ export class Connection {
|
||||
private async registerConnection(conn: DataConnection) {
|
||||
return new Promise<DataConnection>((resolve, reject) => {
|
||||
this.peer.once('error', (err) => {
|
||||
this._mode = 'disconnected';
|
||||
this._state = 'disconnected';
|
||||
|
||||
reject(err);
|
||||
this.connectionListeners.forEach((listener) => listener());
|
||||
@ -133,6 +113,12 @@ export class Connection {
|
||||
this.connectionListeners.forEach((listener) => listener(conn));
|
||||
|
||||
conn.on('data', (data) => {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
@ -140,7 +126,7 @@ export class Connection {
|
||||
!('payload' in data) ||
|
||||
!data.type
|
||||
) {
|
||||
console.warn('Music Together: Invalid data', data);
|
||||
console.warn('Music Together: Invalid data', data, typeof data);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
13
src/plugins/music-together/constants.ts
Normal file
13
src/plugins/music-together/constants.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export const IPC = {
|
||||
prompt: 'music-together:prompt',
|
||||
play: 'music-together:play',
|
||||
pause: 'music-together:pause',
|
||||
previous: 'music-together:previous',
|
||||
next: 'music-together:next',
|
||||
seekTo: 'music-together:seekTo',
|
||||
addSongToQueue: 'music-together:addSongToQueue',
|
||||
removeSongFromQueue: 'music-together:removeSongFromQueue',
|
||||
moveSongInQueue: 'music-together:moveSongInQueue',
|
||||
clearQueue: 'music-together:clearQueue',
|
||||
setQueueIndex: 'music-together:setQueueIndex',
|
||||
};
|
||||
31
src/plugins/music-together/context/RendererContext.tsx
Normal file
31
src/plugins/music-together/context/RendererContext.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { createContext, JSX, splitProps, useContext } from 'solid-js';
|
||||
|
||||
import { MusicTogetherConfig } from '../types';
|
||||
|
||||
import { RendererContext } from '@/types/contexts';
|
||||
|
||||
export type RendererContextContextType = {
|
||||
context: RendererContext<MusicTogetherConfig>;
|
||||
};
|
||||
export const RendererContextContext =
|
||||
createContext<RendererContextContextType>();
|
||||
|
||||
export type RendererContextProviderProps = RendererContextContextType & {
|
||||
children: JSX.Element;
|
||||
};
|
||||
export const RendererContextProvider = (
|
||||
props: RendererContextProviderProps,
|
||||
) => {
|
||||
const [local, left] = splitProps(props, ['children']);
|
||||
return (
|
||||
<RendererContextContext.Provider value={left}>
|
||||
{local.children}
|
||||
</RendererContextContext.Provider>
|
||||
);
|
||||
};
|
||||
export const useRendererContext = () => {
|
||||
const context = useContext(RendererContextContext);
|
||||
if (!context) throw Error('RendererContextProvider not found');
|
||||
|
||||
return context.context;
|
||||
};
|
||||
22
src/plugins/music-together/context/ToastContext.tsx
Normal file
22
src/plugins/music-together/context/ToastContext.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { createContext, JSX, useContext } from 'solid-js';
|
||||
|
||||
import { ToastService } from '@/types/queue';
|
||||
|
||||
export type ToastContextType = {
|
||||
service: ToastService;
|
||||
};
|
||||
export const ToastContext = createContext<ToastContextType>();
|
||||
|
||||
export type ToastProviderProps = ToastContextType & {
|
||||
children: JSX.Element;
|
||||
};
|
||||
export const ToastProvider = (props: ToastProviderProps) => (
|
||||
<ToastContext.Provider value={props}>{props.children}</ToastContext.Provider>
|
||||
);
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
|
||||
return (message: string) => {
|
||||
context?.service.show(message);
|
||||
};
|
||||
};
|
||||
@ -1,148 +0,0 @@
|
||||
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||
|
||||
import itemHTML from './templates/item.html?raw';
|
||||
import popupHTML from './templates/popup.html?raw';
|
||||
|
||||
type Placement =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'right'
|
||||
| 'left'
|
||||
| 'center'
|
||||
| 'middle'
|
||||
| 'center-middle'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
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 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,
|
||||
};
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
container.append(...items.map(({ element }) => element));
|
||||
popup.style.setProperty('opacity', '0');
|
||||
popup.style.setProperty('pointer-events', 'none');
|
||||
|
||||
document.body.append(popup);
|
||||
|
||||
return {
|
||||
element: popup,
|
||||
container,
|
||||
items,
|
||||
|
||||
show(x: number, y: number, anchor?: HTMLElement) {
|
||||
let left = x;
|
||||
let top = y;
|
||||
|
||||
if (anchor) {
|
||||
if (props.anchorAt?.includes('right')) left += anchor.clientWidth;
|
||||
if (props.anchorAt?.includes('bottom')) top += anchor.clientHeight;
|
||||
if (props.anchorAt?.includes('center')) left += anchor.clientWidth / 2;
|
||||
if (props.anchorAt?.includes('middle')) top += anchor.clientHeight / 2;
|
||||
}
|
||||
|
||||
if (props.popupAt?.includes('right')) left -= popup.clientWidth;
|
||||
if (props.popupAt?.includes('bottom')) top -= popup.clientHeight;
|
||||
if (props.popupAt?.includes('center')) left -= popup.clientWidth / 2;
|
||||
if (props.popupAt?.includes('middle')) top -= popup.clientHeight / 2;
|
||||
|
||||
popup.style.setProperty('left', `${left}px`);
|
||||
popup.style.setProperty('top', `${top}px`);
|
||||
popup.style.setProperty('opacity', '1');
|
||||
popup.style.setProperty('pointer-events', 'unset');
|
||||
|
||||
setTimeout(() => {
|
||||
const onClose = (event: MouseEvent) => {
|
||||
const isPopupClick = event
|
||||
.composedPath()
|
||||
.some((element) => element === popup);
|
||||
if (!isPopupClick) {
|
||||
this.dismiss();
|
||||
document.removeEventListener('click', onClose);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', onClose);
|
||||
}, 16);
|
||||
},
|
||||
showAtAnchor(anchor: HTMLElement) {
|
||||
const { x, y } = anchor.getBoundingClientRect();
|
||||
this.show(x, y, anchor);
|
||||
},
|
||||
|
||||
isShowing() {
|
||||
return popup.style.getPropertyValue('opacity') === '1';
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
popup.style.setProperty('opacity', '0');
|
||||
popup.style.setProperty('pointer-events', 'none');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type ItemRendererProps = {
|
||||
id?: string;
|
||||
icon?: Element;
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export const ItemRenderer = (props: ItemRendererProps) => {
|
||||
const element = ElementFromHtml(itemHTML);
|
||||
const iconContainer = element.querySelector<HTMLElement>('div.icon')!;
|
||||
const textContainer = element.querySelector<HTMLElement>('div.text')!;
|
||||
if (props.icon) iconContainer.appendChild(props.icon);
|
||||
textContainer.append(props.text);
|
||||
|
||||
if (props.onClick) {
|
||||
element.addEventListener('click', () => {
|
||||
props.onClick?.();
|
||||
});
|
||||
}
|
||||
if (props.id) element.id = props.id;
|
||||
|
||||
return {
|
||||
element,
|
||||
setIcon(icon: Element) {
|
||||
iconContainer.replaceChildren(icon);
|
||||
},
|
||||
setText(text: string) {
|
||||
textContainer.replaceChildren(text);
|
||||
},
|
||||
id: props.id,
|
||||
};
|
||||
};
|
||||
@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M480-640 280-440l56 56 104-103v407h80v-407l104 103 56-56-200-200ZM146-260q-32-49-49-105T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 59-17 115t-49 105l-58-58q22-37 33-78t11-84q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 43 11 84t33 78l-58 58Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 408 B |
@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="M280-400q-33 0-56.5-23.5T200-480q0-33 23.5-56.5T280-560q33 0 56.5 23.5T360-480q0 33-23.5 56.5T280-400Zm0 160q-100 0-170-70T40-480q0-100 70-170t170-70q67 0 121.5 33t86.5 87h352l120 120-180 180-80-60-80 60-85-60h-47q-32 54-86.5 87T280-240Zm0-80q56 0 98.5-34t56.5-86h125l58 41 82-61 71 55 75-75-40-40H435q-14-52-56.5-86T280-640q-66 0-113 47t-47 113q0 66 47 113t113 47Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 480 B |
@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M560-160q-66 0-113-47t-47-113q0-66 47-113t113-47q23 0 42.5 5.5T640-458v-342h240v120H720v360q0 66-47 113t-113 47ZM80-320q0-99 38-186.5T221-659q65-65 152.5-103T560-800v80q-82 0-155 31.5t-127.5 86q-54.5 54.5-86 127T160-320H80Zm160 0q0-66 25.5-124.5t69-102Q378-590 436-615t124-25v80q-100 0-170 70t-70 170h-80Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 416 B |
@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="M792-56 686-160H260q-92 0-156-64T40-380q0-77 47.5-137T210-594q3-8 6-15.5t6-16.5L56-792l56-56 736 736-56 56ZM260-240h346L284-562q-2 11-3 21t-1 21h-20q-58 0-99 41t-41 99q0 58 41 99t99 41Zm185-161Zm419 191-58-56q17-14 25.5-32.5T840-340q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-27 0-52 6.5T380-693l-58-58q35-24 74.5-36.5T480-800q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 39-15 72.5T864-210ZM593-479Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 529 B |
@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 298 B |
@ -1,84 +1,10 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { DataConnection } from 'peerjs';
|
||||
|
||||
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 { Queue } from './queue';
|
||||
import { Connection, type ConnectionEventUnion } from './connection';
|
||||
import { createHostPopup } from './ui/host';
|
||||
import { createGuestPopup } from './ui/guest';
|
||||
import { createSettingPopup } from './ui/setting';
|
||||
import { onMainLoad } from './backend';
|
||||
import { onRendererLoad } from './src';
|
||||
|
||||
import settingHTML from './templates/setting.html?raw';
|
||||
import style from './style.css?inline';
|
||||
|
||||
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { VideoDataChanged } from '@/types/video-data-changed';
|
||||
import type { AppElement } from '@/types/queue';
|
||||
|
||||
type RawAccountData = {
|
||||
accountName: {
|
||||
runs: { text: string }[];
|
||||
};
|
||||
accountPhoto: {
|
||||
thumbnails: { url: string; width: number; height: number }[];
|
||||
};
|
||||
settingsEndpoint: unknown;
|
||||
manageAccountTitle: unknown;
|
||||
trackingParams: string;
|
||||
channelHandle: {
|
||||
runs: { text: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
export default createPlugin<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
connection?: Connection;
|
||||
ipc?: RendererContext<never>['ipc'];
|
||||
api: AppElement | null;
|
||||
queue?: Queue;
|
||||
playerApi?: YoutubePlayer;
|
||||
showPrompt: (title: string, label: string) => Promise<string>;
|
||||
popups: {
|
||||
host: ReturnType<typeof createHostPopup>;
|
||||
guest: ReturnType<typeof createGuestPopup>;
|
||||
setting: ReturnType<typeof createSettingPopup>;
|
||||
};
|
||||
elements: {
|
||||
setting: HTMLElement;
|
||||
icon: SVGElement;
|
||||
spinner: HTMLElement;
|
||||
};
|
||||
stateInterval?: number;
|
||||
updateNext: boolean;
|
||||
ignoreChange: boolean;
|
||||
rollbackInjector?: () => void;
|
||||
me?: Omit<Profile, 'id'>;
|
||||
profiles: Record<string, Profile>;
|
||||
permission: Permission;
|
||||
videoChangeListener: (event: CustomEvent<VideoDataChanged>) => void;
|
||||
videoStateChangeListener: () => void;
|
||||
onHost: () => Promise<boolean>;
|
||||
onJoin: () => Promise<boolean>;
|
||||
onStop: () => void;
|
||||
putProfile: (id: string, profile?: Profile) => void;
|
||||
showSpinner: () => void;
|
||||
hideSpinner: () => void;
|
||||
initMyProfile: () => void;
|
||||
}
|
||||
>({
|
||||
export default createPlugin({
|
||||
name: () => t('plugins.music-together.name'),
|
||||
description: () => t('plugins.music-together.description'),
|
||||
restartNeeded: false,
|
||||
@ -86,757 +12,9 @@ export default createPlugin<
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
stylesheets: [style],
|
||||
backend({ ipc }) {
|
||||
ipc.handle('music-together:prompt', async (title: string, label: string) =>
|
||||
prompt({
|
||||
title,
|
||||
label,
|
||||
type: 'input',
|
||||
...promptOptions(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
stylesheets: [],
|
||||
backend: onMainLoad,
|
||||
renderer: {
|
||||
updateNext: false,
|
||||
ignoreChange: false,
|
||||
permission: 'playlist',
|
||||
popups: {} as {
|
||||
host: ReturnType<typeof createHostPopup>;
|
||||
guest: ReturnType<typeof createGuestPopup>;
|
||||
setting: ReturnType<typeof createSettingPopup>;
|
||||
},
|
||||
elements: {} as {
|
||||
setting: HTMLElement;
|
||||
icon: SVGElement;
|
||||
spinner: HTMLElement;
|
||||
},
|
||||
profiles: {},
|
||||
showPrompt: () => Promise.resolve(''),
|
||||
api: null,
|
||||
|
||||
/* events */
|
||||
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,
|
||||
) ?? [];
|
||||
|
||||
this.queue?.setVideoList(videoList, false);
|
||||
this.queue?.syncQueueOwner();
|
||||
this.connection.broadcast('SYNC_QUEUE', {
|
||||
videoList,
|
||||
});
|
||||
|
||||
this.updateNext = event.detail.name === 'dataloaded';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
videoStateChangeListener() {
|
||||
if (this.connection?.mode !== 'guest') return;
|
||||
if (this.ignoreChange) return;
|
||||
if (this.permission !== 'all') return;
|
||||
|
||||
const state = this.playerApi?.getPlayerState();
|
||||
if (state !== 1 && state !== 2) return;
|
||||
|
||||
this.connection.broadcast('SYNC_PROGRESS', {
|
||||
// progress: this.playerApi?.getCurrentTime(),
|
||||
state: this.playerApi?.getPlayerState(),
|
||||
// index: this.queue?.selectedIndex ?? 0,
|
||||
});
|
||||
},
|
||||
|
||||
/* connection */
|
||||
async onHost() {
|
||||
this.connection = new Connection();
|
||||
const wait = await this.connection.waitForReady().catch(() => null);
|
||||
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,
|
||||
) ?? [];
|
||||
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(
|
||||
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.putProfile(connection.peer, undefined);
|
||||
}
|
||||
});
|
||||
this.putProfile(this.connection.id, {
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
|
||||
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.connection?.broadcast('ADD_SONGS', event.payload);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
if (conn && this.permission === 'host-only') return;
|
||||
|
||||
this.queue?.removeVideo(event.payload.index);
|
||||
await this.connection?.broadcast('REMOVE_SONG', event.payload);
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
if (conn && this.permission === 'host-only') {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
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',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 'PERMISSION': {
|
||||
await this.connection?.broadcast('PERMISSION', this.permission);
|
||||
this.popups.guest.setPermission(this.permission);
|
||||
this.popups.host.setPermission(this.permission);
|
||||
this.popups.setting.setPermission(this.permission);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_QUEUE': {
|
||||
await this.connection?.broadcast('SYNC_QUEUE', {
|
||||
videoList: this.queue?.videoList ?? [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'SYNC_PROGRESS': {
|
||||
let permissionLevel = 0;
|
||||
if (this.permission === 'all') permissionLevel = 2;
|
||||
if (this.permission === 'playlist') permissionLevel = 1;
|
||||
if (this.permission === 'host-only') permissionLevel = 0;
|
||||
if (!conn) permissionLevel = 3;
|
||||
|
||||
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 (this.playerApi?.getPlayerState() !== event.payload?.state) {
|
||||
if (event.payload?.state === 2) this.playerApi?.pauseVideo();
|
||||
if (event.payload?.state === 1) this.playerApi?.playVideo();
|
||||
}
|
||||
}
|
||||
if (permissionLevel >= 1) {
|
||||
if (typeof event.payload?.index === 'number') {
|
||||
const nowIndex = this.queue?.selectedIndex ?? 0;
|
||||
|
||||
if (nowIndex !== event.payload.index) {
|
||||
this.queue?.setIndex(event.payload.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Music Together [Host]: Unknown Event', event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.after) {
|
||||
const now = event.after.shift();
|
||||
if (now) {
|
||||
now.after = event.after;
|
||||
await listener(now, conn);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.connection.on(listener);
|
||||
this.queue?.on(listener);
|
||||
|
||||
setTimeout(() => {
|
||||
this.ignoreChange = false;
|
||||
}, 16); // wait 1 frame
|
||||
return true;
|
||||
},
|
||||
|
||||
async onJoin() {
|
||||
this.connection = new Connection();
|
||||
const wait = await this.connection.waitForReady().catch(() => null);
|
||||
if (!wait) return false;
|
||||
|
||||
this.profiles = {};
|
||||
|
||||
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.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,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'REMOVE_SONG': {
|
||||
this.queue?.removeVideo(event.payload.index);
|
||||
break;
|
||||
}
|
||||
case 'MOVE_SONG': {
|
||||
this.queue?.moveItem(
|
||||
event.payload.fromIndex,
|
||||
event.payload.toIndex,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'IDENTIFY': {
|
||||
console.warn(
|
||||
'Music Together [Guest]: Received "IDENTIFY" event from guest',
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'SYNC_QUEUE': {
|
||||
if (Array.isArray(event.payload?.videoList)) {
|
||||
await this.queue?.setVideoList(event.payload.videoList);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'SYNC_PROFILE': {
|
||||
if (!event.payload) {
|
||||
console.warn(
|
||||
'Music Together [Guest]: Received "SYNC_PROFILE" event without payload',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
Object.entries(event.payload.profiles).forEach(([id, profile]) => {
|
||||
this.putProfile(id, profile);
|
||||
});
|
||||
break;
|
||||
}
|
||||
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 (this.playerApi?.getPlayerState() !== event.payload?.state) {
|
||||
if (event.payload?.state === 2) this.playerApi?.pauseVideo();
|
||||
if (event.payload?.state === 1) this.playerApi?.playVideo();
|
||||
}
|
||||
if (typeof event.payload?.index === 'number') {
|
||||
const nowIndex = this.queue?.selectedIndex ?? 0;
|
||||
|
||||
if (nowIndex !== event.payload.index) {
|
||||
this.queue?.setIndex(event.payload.index);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'PERMISSION': {
|
||||
if (!event.payload) {
|
||||
console.warn(
|
||||
'Music Together [Guest]: Received "PERMISSION" event without payload',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
this.permission = event.payload;
|
||||
this.popups.guest.setPermission(this.permission);
|
||||
this.popups.host.setPermission(this.permission);
|
||||
this.popups.setting.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,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Music Together [Guest]: Unknown Event', event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof resolveIgnore === 'number') clearTimeout(resolveIgnore);
|
||||
resolveIgnore = window.setTimeout(() => {
|
||||
this.ignoreChange = false;
|
||||
}, 16); // wait 1 frame
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
if (!this.me) this.me = getDefaultProfile(this.connection.id);
|
||||
this.queue?.injection();
|
||||
this.queue?.setOwner({
|
||||
id: this.connection.id,
|
||||
...this.me,
|
||||
});
|
||||
|
||||
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) => {
|
||||
const now = args[0];
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
original.call(progress, ...args);
|
||||
};
|
||||
|
||||
return () => {
|
||||
progress._update = original;
|
||||
};
|
||||
});
|
||||
this.rollbackInjector = () => {
|
||||
rollbackList.forEach((rollback) => rollback());
|
||||
};
|
||||
|
||||
this.connection.broadcast('IDENTIFY', {
|
||||
profile: {
|
||||
id: this.connection.id,
|
||||
handleId: this.me.handleId,
|
||||
name: this.me.name,
|
||||
thumbnail: this.me.thumbnail,
|
||||
},
|
||||
});
|
||||
|
||||
this.connection.broadcast('SYNC_PROFILE', undefined);
|
||||
this.connection.broadcast('PERMISSION', undefined);
|
||||
|
||||
this.queue?.clear();
|
||||
this.queue?.syncQueueOwner();
|
||||
this.queue?.initQueue();
|
||||
|
||||
this.connection.broadcast('SYNC_QUEUE', undefined);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
onStop() {
|
||||
this.connection?.disconnect();
|
||||
this.queue?.rollbackInjection();
|
||||
this.queue?.removeQueueOwner();
|
||||
if (this.rollbackInjector) {
|
||||
this.rollbackInjector();
|
||||
this.rollbackInjector = undefined;
|
||||
}
|
||||
|
||||
this.profiles = {};
|
||||
this.popups.host.setUsers(Object.values(this.profiles));
|
||||
this.popups.guest.setUsers(Object.values(this.profiles));
|
||||
|
||||
this.popups.host.dismiss();
|
||||
this.popups.guest.dismiss();
|
||||
this.popups.setting.dismiss();
|
||||
},
|
||||
|
||||
/* methods */
|
||||
putProfile(id: string, profile?: Profile) {
|
||||
if (profile === undefined) {
|
||||
delete this.profiles[id];
|
||||
} else {
|
||||
this.profiles[id] = profile;
|
||||
}
|
||||
|
||||
this.popups.host.setUsers(Object.values(this.profiles));
|
||||
this.popups.guest.setUsers(Object.values(this.profiles));
|
||||
},
|
||||
|
||||
showSpinner() {
|
||||
this.elements.icon.style.setProperty('display', 'none');
|
||||
this.elements.spinner.removeAttribute('hidden');
|
||||
this.elements.spinner.setAttribute('active', '');
|
||||
},
|
||||
|
||||
hideSpinner() {
|
||||
this.elements.icon.style.removeProperty('display');
|
||||
this.elements.spinner.removeAttribute('active');
|
||||
this.elements.spinner.setAttribute('hidden', '');
|
||||
},
|
||||
|
||||
initMyProfile() {
|
||||
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');
|
||||
if (!accountButton || !renderer) {
|
||||
console.warn('Music Together: Cannot find account');
|
||||
this.me = getDefaultProfile(this.connection?.id ?? '');
|
||||
return;
|
||||
}
|
||||
|
||||
const accountData = renderer.data as RawAccountData;
|
||||
this.me = {
|
||||
handleId:
|
||||
accountData.channelHandle.runs[0].text ??
|
||||
accountData.accountName.runs[0].text,
|
||||
name: accountData.accountName.runs[0].text,
|
||||
thumbnail: accountData.accountPhoto.thumbnails[0].url,
|
||||
};
|
||||
|
||||
if (this.me.thumbnail) {
|
||||
this.popups.host.setProfile(this.me.thumbnail);
|
||||
this.popups.guest.setProfile(this.me.thumbnail);
|
||||
this.popups.setting.setProfile(this.me.thumbnail);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
/* hooks */
|
||||
|
||||
start({ ipc }) {
|
||||
this.ipc = ipc;
|
||||
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',
|
||||
);
|
||||
if (!setting || !icon || !spinner) {
|
||||
console.warn('Music Together: Cannot inject html');
|
||||
console.log(setting, icon, spinner);
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements = {
|
||||
setting,
|
||||
icon,
|
||||
spinner,
|
||||
};
|
||||
|
||||
this.stateInterval = window.setInterval(() => {
|
||||
if (this.connection?.mode !== 'host') return;
|
||||
const index = this.queue?.selectedIndex ?? 0;
|
||||
|
||||
this.connection.broadcast('SYNC_PROGRESS', {
|
||||
progress: this.playerApi?.getCurrentTime(),
|
||||
state: this.playerApi?.getPlayerState(),
|
||||
index,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
/* UI */
|
||||
const hostPopup = createHostPopup({
|
||||
onItemClick: (id) => {
|
||||
if (id === 'music-together-close') {
|
||||
this.onStop();
|
||||
this.api?.toastService?.show(
|
||||
t('plugins.music-together.toast.closed'),
|
||||
);
|
||||
hostPopup.dismiss();
|
||||
}
|
||||
|
||||
if (id === 'music-together-copy-id') {
|
||||
navigator.clipboard
|
||||
.writeText(this.connection?.id ?? '')
|
||||
.then(() => {
|
||||
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'),
|
||||
);
|
||||
hostPopup.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
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 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'),
|
||||
);
|
||||
guestPopup.dismiss();
|
||||
}
|
||||
},
|
||||
});
|
||||
const settingPopup = createSettingPopup({
|
||||
onItemClick: async (id) => {
|
||||
if (id === 'music-together-host') {
|
||||
settingPopup.dismiss();
|
||||
this.showSpinner();
|
||||
const result = await this.onHost();
|
||||
this.hideSpinner();
|
||||
|
||||
if (result) {
|
||||
navigator.clipboard
|
||||
.writeText(this.connection?.id ?? '')
|
||||
.then(() => {
|
||||
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'),
|
||||
);
|
||||
hostPopup.showAtAnchor(setting);
|
||||
});
|
||||
} else {
|
||||
this.api?.toastService?.show(
|
||||
t('plugins.music-together.toast.host-failed'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (id === 'music-together-join') {
|
||||
settingPopup.dismiss();
|
||||
this.showSpinner();
|
||||
const result = await this.onJoin();
|
||||
this.hideSpinner();
|
||||
|
||||
if (result) {
|
||||
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.popups = {
|
||||
host: hostPopup,
|
||||
guest: guestPopup,
|
||||
setting: settingPopup,
|
||||
};
|
||||
setting.addEventListener('click', () => {
|
||||
let popup = settingPopup;
|
||||
if (this.connection?.mode === 'host') popup = hostPopup;
|
||||
if (this.connection?.mode === 'guest') popup = guestPopup;
|
||||
|
||||
if (popup.isShowing()) popup.dismiss();
|
||||
else popup.showAtAnchor(setting);
|
||||
});
|
||||
|
||||
/* account data getter */
|
||||
this.initMyProfile();
|
||||
},
|
||||
onPlayerApiReady(playerApi) {
|
||||
this.queue = new Queue({
|
||||
owner: {
|
||||
id: this.connection?.id ?? '',
|
||||
...this.me!,
|
||||
},
|
||||
getProfile: (id) => this.profiles[id],
|
||||
});
|
||||
this.playerApi = playerApi;
|
||||
|
||||
this.playerApi.addEventListener(
|
||||
'onStateChange',
|
||||
this.videoStateChangeListener,
|
||||
);
|
||||
document.addEventListener('videodatachange', this.videoChangeListener);
|
||||
},
|
||||
stop() {
|
||||
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,
|
||||
);
|
||||
},
|
||||
start: onRendererLoad,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
import { SHA1Hash } from './sha1hash';
|
||||
|
||||
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 getAuthorizationHeader = async (
|
||||
papisid: string,
|
||||
millis = Date.now(),
|
||||
origin: string = 'https://music.youtube.com',
|
||||
) => {
|
||||
return `SAPISIDHASH ${millis}_${await getHash(papisid, millis, origin)}`;
|
||||
};
|
||||
|
||||
export const getClient = () => {
|
||||
return {
|
||||
hl: navigator.language.split('-')[0] ?? 'en',
|
||||
gl: navigator.language.split('-')[1] ?? 'US',
|
||||
deviceMake: '',
|
||||
deviceModel: '',
|
||||
userAgent: navigator.userAgent,
|
||||
clientName: 'WEB_REMIX',
|
||||
clientVersion: '1.20231208.05.02',
|
||||
osName: '',
|
||||
osVersion: '',
|
||||
platform: 'DESKTOP',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
locationInfo: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
utcOffsetMinutes: -1 * new Date().getTimezoneOffset(),
|
||||
};
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from './queue';
|
||||
@ -1,544 +0,0 @@
|
||||
import { getMusicQueueRenderer } from './song';
|
||||
import { mapQueueItem } from './utils';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
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';
|
||||
|
||||
const getHeaderPayload = (() => {
|
||||
let payload: {
|
||||
items?: QueueItem[] | undefined;
|
||||
title: {
|
||||
runs: {
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
subtitle: {
|
||||
runs: {
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
buttons: {
|
||||
chipCloudChipRenderer: {
|
||||
style: {
|
||||
styleType: string;
|
||||
};
|
||||
text: {
|
||||
runs: {
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
navigationEndpoint: {
|
||||
saveQueueToPlaylistCommand: unknown;
|
||||
};
|
||||
icon: {
|
||||
iconType: string;
|
||||
};
|
||||
accessibilityData: {
|
||||
accessibilityData: {
|
||||
label: string;
|
||||
};
|
||||
};
|
||||
isSelected: boolean;
|
||||
uniqueId: string;
|
||||
};
|
||||
}[];
|
||||
} | null = null;
|
||||
|
||||
return () => {
|
||||
if (!payload) {
|
||||
payload = {
|
||||
title: {
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.music-together.internal.track-source'),
|
||||
},
|
||||
],
|
||||
},
|
||||
subtitle: {
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.music-together.name'),
|
||||
},
|
||||
],
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
chipCloudChipRenderer: {
|
||||
style: {
|
||||
styleType: 'STYLE_TRANSPARENT',
|
||||
},
|
||||
text: {
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.music-together.internal.save'),
|
||||
},
|
||||
],
|
||||
},
|
||||
navigationEndpoint: {
|
||||
saveQueueToPlaylistCommand: {},
|
||||
},
|
||||
icon: {
|
||||
iconType: 'ADD_TO_PLAYLIST',
|
||||
},
|
||||
accessibilityData: {
|
||||
accessibilityData: {
|
||||
label: t('plugins.music-together.internal.save'),
|
||||
},
|
||||
},
|
||||
isSelected: false,
|
||||
uniqueId: t('plugins.music-together.internal.save'),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
})();
|
||||
|
||||
export type QueueOptions = {
|
||||
videoList?: VideoData[];
|
||||
owner?: Profile;
|
||||
queue?: QueueElement;
|
||||
getProfile: (id: string) => Profile | undefined;
|
||||
};
|
||||
export type QueueEventListener = (event: ConnectionEventUnion) => void;
|
||||
|
||||
export class Queue {
|
||||
private readonly queue: QueueElement;
|
||||
|
||||
private originalDispatch?: (obj: {
|
||||
type: string;
|
||||
payload?: { items?: QueueItem[] | undefined };
|
||||
}) => void;
|
||||
|
||||
private internalDispatch = false;
|
||||
private ignoreFlag = false;
|
||||
private listeners: QueueEventListener[] = [];
|
||||
|
||||
private owner: Profile | null;
|
||||
private readonly getProfile: (id: string) => Profile | undefined;
|
||||
|
||||
constructor(options: QueueOptions) {
|
||||
this.getProfile = options.getProfile;
|
||||
this.queue =
|
||||
options.queue ?? document.querySelector<QueueElement>('#queue')!;
|
||||
this.owner = options.owner ?? null;
|
||||
this._videoList = options.videoList ?? [];
|
||||
}
|
||||
|
||||
private _videoList: VideoData[] = [];
|
||||
|
||||
/* utils */
|
||||
get videoList() {
|
||||
return this._videoList;
|
||||
}
|
||||
|
||||
get selectedIndex() {
|
||||
return (
|
||||
mapQueueItem(
|
||||
(it) => it?.selected,
|
||||
this.queue.queue.store.store.getState().queue.items,
|
||||
).findIndex(Boolean) ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
get rawItems() {
|
||||
return this.queue?.queue.store.store.getState().queue.items;
|
||||
}
|
||||
|
||||
get flatItems() {
|
||||
return mapQueueItem((it) => it, this.rawItems);
|
||||
}
|
||||
|
||||
setOwner(owner: Profile) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
/* public */
|
||||
async setVideoList(videoList: VideoData[], sync = true) {
|
||||
this._videoList = videoList;
|
||||
|
||||
if (sync) await this.syncVideo();
|
||||
}
|
||||
|
||||
async addVideos(videos: VideoData[], index?: number) {
|
||||
const response = await getMusicQueueRenderer(
|
||||
videos.map((it) => it.videoId),
|
||||
);
|
||||
if (!response) return false;
|
||||
|
||||
const items = response.queueDatas.map((it) => it?.content).filter(Boolean);
|
||||
if (!items) return false;
|
||||
|
||||
this.internalDispatch = true;
|
||||
this._videoList.push(...videos);
|
||||
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,
|
||||
items,
|
||||
shuffleEnabled: false,
|
||||
shouldAssignIds: true,
|
||||
},
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
this.initQueue();
|
||||
this.syncQueueOwner();
|
||||
}, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
removeVideo(index: number) {
|
||||
this.internalDispatch = true;
|
||||
this._videoList.splice(index, 1);
|
||||
this.queue?.dispatch({
|
||||
type: 'REMOVE_ITEM',
|
||||
payload: index,
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
this.initQueue();
|
||||
this.syncQueueOwner();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
setIndex(index: number) {
|
||||
this.internalDispatch = true;
|
||||
this.queue?.dispatch({
|
||||
type: 'SET_INDEX',
|
||||
payload: index,
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
}
|
||||
|
||||
moveItem(fromIndex: number, toIndex: number) {
|
||||
this.internalDispatch = true;
|
||||
const data = this._videoList.splice(fromIndex, 1)[0];
|
||||
this._videoList.splice(toIndex, 0, data);
|
||||
this.queue?.dispatch({
|
||||
type: 'MOVE_ITEM',
|
||||
payload: {
|
||||
fromIndex,
|
||||
toIndex,
|
||||
},
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
this.initQueue();
|
||||
this.syncQueueOwner();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.internalDispatch = true;
|
||||
this._videoList = [];
|
||||
this.queue?.dispatch({
|
||||
type: 'CLEAR',
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
}
|
||||
|
||||
on(listener: QueueEventListener) {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
|
||||
off(listener: QueueEventListener) {
|
||||
this.listeners = this.listeners.filter((it) => it !== listener);
|
||||
}
|
||||
|
||||
rollbackInjection() {
|
||||
if (!this.queue) {
|
||||
console.error('Queue is not initialized!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.originalDispatch)
|
||||
this.queue.queue.store.store.dispatch = this
|
||||
.originalDispatch as Store['dispatch'];
|
||||
}
|
||||
|
||||
injection() {
|
||||
if (!this.queue) {
|
||||
console.error('Queue is not initialized!');
|
||||
return;
|
||||
}
|
||||
|
||||
this.originalDispatch = this.queue.queue.store.store.dispatch;
|
||||
this.queue.queue.store.store.dispatch = (event) => {
|
||||
if (!this.queue || !this.owner) {
|
||||
console.error('Queue is not initialized!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.internalDispatch) {
|
||||
if (event.type === 'CLEAR') {
|
||||
this.ignoreFlag = true;
|
||||
}
|
||||
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! as {
|
||||
items: QueueItem[];
|
||||
}
|
||||
).items,
|
||||
);
|
||||
const index = this._videoList.length + videoList.length - 1;
|
||||
|
||||
if (videoList.length > 0) {
|
||||
this.broadcast({
|
||||
// play
|
||||
type: 'ADD_SONGS',
|
||||
payload: {
|
||||
videoList,
|
||||
},
|
||||
after: [
|
||||
{
|
||||
type: 'SYNC_PROGRESS',
|
||||
payload: {
|
||||
index,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} 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! as {
|
||||
items: QueueItem[];
|
||||
}
|
||||
).items,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'MOVE_ITEM') {
|
||||
this.broadcast({
|
||||
type: 'MOVE_SONG',
|
||||
payload: {
|
||||
fromIndex: (
|
||||
event.payload as {
|
||||
fromIndex: number;
|
||||
}
|
||||
).fromIndex,
|
||||
toIndex: (
|
||||
event.payload as {
|
||||
toIndex: number;
|
||||
}
|
||||
).toIndex,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === 'REMOVE_ITEM') {
|
||||
this.broadcast({
|
||||
type: 'REMOVE_SONG',
|
||||
payload: {
|
||||
index: event.payload as number,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === 'SET_INDEX') {
|
||||
this.broadcast({
|
||||
type: 'SYNC_PROGRESS',
|
||||
payload: {
|
||||
index: event.payload as number,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'SET_HEADER') event.payload = getHeaderPayload();
|
||||
if (event.type === 'ADD_STEERING_CHIPS') {
|
||||
event.type = 'CLEAR_STEERING_CHIPS';
|
||||
event.payload = undefined;
|
||||
}
|
||||
if (event.type === 'SET_PLAYER_UI_STATE') {
|
||||
if (
|
||||
(event.payload as string) === 'INACTIVE' &&
|
||||
this.videoList.length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.type === 'HAS_SHOWN_AUTOPLAY') return;
|
||||
if (event.type === 'ADD_AUTOMIX_ITEMS') return;
|
||||
}
|
||||
|
||||
const fakeContext = {
|
||||
...this.queue,
|
||||
queue: {
|
||||
...this.queue.queue,
|
||||
store: {
|
||||
...this.queue.queue.store,
|
||||
dispatch: this.originalDispatch,
|
||||
},
|
||||
},
|
||||
};
|
||||
this.originalDispatch?.call(
|
||||
fakeContext,
|
||||
event as {
|
||||
type: string;
|
||||
payload?: { items?: QueueItem[] | undefined } | undefined;
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/* sync */
|
||||
initQueue() {
|
||||
if (!this.queue) return;
|
||||
|
||||
this.internalDispatch = true;
|
||||
this.queue.dispatch({
|
||||
type: 'HAS_SHOWN_AUTOPLAY',
|
||||
payload: false,
|
||||
});
|
||||
this.queue.dispatch({
|
||||
type: 'SET_HEADER',
|
||||
payload: getHeaderPayload(),
|
||||
});
|
||||
this.queue.dispatch({
|
||||
type: 'CLEAR_STEERING_CHIPS',
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
}
|
||||
|
||||
async syncVideo() {
|
||||
const response = await getMusicQueueRenderer(
|
||||
this._videoList.map((it) => it.videoId),
|
||||
);
|
||||
if (!response) return false;
|
||||
|
||||
const items = response.queueDatas.map((it) => it.content);
|
||||
|
||||
this.internalDispatch = true;
|
||||
this.queue?.dispatch({
|
||||
type: 'UPDATE_ITEMS',
|
||||
payload: {
|
||||
items: items,
|
||||
nextQueueItemId:
|
||||
this.queue.queue.store.store.getState().queue.nextQueueItemId,
|
||||
shouldAssignIds: true,
|
||||
currentIndex: -1,
|
||||
},
|
||||
});
|
||||
this.internalDispatch = false;
|
||||
setTimeout(() => {
|
||||
this.initQueue();
|
||||
this.syncQueueOwner();
|
||||
}, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
syncQueueOwner() {
|
||||
const allQueue = document.querySelectorAll('#queue');
|
||||
|
||||
allQueue.forEach((queue) => {
|
||||
const list = Array.from(
|
||||
queue?.querySelectorAll<HTMLElement>('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);
|
||||
|
||||
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');
|
||||
name.classList.add('music-together-name');
|
||||
name.textContent =
|
||||
data?.name ?? t('plugins.music-together.internal.unknown-user');
|
||||
|
||||
if (data) {
|
||||
profile.dataset.thumbnail = data.thumbnail ?? '';
|
||||
profile.dataset.name = data.name ?? '';
|
||||
profile.dataset.handleId = data.handleId ?? '';
|
||||
profile.dataset.id = data.id ?? '';
|
||||
|
||||
profile.src = data.thumbnail ?? '';
|
||||
profile.title = data.name ?? '';
|
||||
profile.alt = data.handleId ?? '';
|
||||
}
|
||||
|
||||
if (!profile.isConnected) item.append(profile);
|
||||
if (!name.isConnected) item.append(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
removeQueueOwner() {
|
||||
const allQueue = document.querySelectorAll('#queue');
|
||||
|
||||
allQueue.forEach((queue) => {
|
||||
const list = Array.from(
|
||||
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
|
||||
);
|
||||
|
||||
list.forEach((item) => {
|
||||
const profile = item.querySelector<HTMLImageElement>(
|
||||
'.music-together-owner',
|
||||
);
|
||||
const name = item.querySelector<HTMLElement>('.music-together-name');
|
||||
profile?.remove();
|
||||
name?.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* private */
|
||||
private broadcast(event: ConnectionEventUnion) {
|
||||
this.listeners.forEach((listener) => listener(event));
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
export const SHA1Hash = async (str: string) => {
|
||||
const enc = new TextEncoder();
|
||||
const hash = await crypto.subtle.digest('SHA-1', enc.encode(str));
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((v) => v.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
};
|
||||
@ -1,50 +0,0 @@
|
||||
import { extractToken, getAuthorizationHeader, getClient } from './client';
|
||||
|
||||
type QueueRendererResponse = {
|
||||
queueDatas: {
|
||||
content: unknown;
|
||||
}[];
|
||||
responseContext: unknown;
|
||||
trackingParams: string;
|
||||
};
|
||||
|
||||
export const getMusicQueueRenderer = async (
|
||||
videoIds: string[],
|
||||
): Promise<QueueRendererResponse | null> => {
|
||||
const token = extractToken();
|
||||
if (!token) return null;
|
||||
|
||||
const response = await fetch(
|
||||
'https://music.youtube.com/youtubei/v1/music/get_queue?key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30&prettyPrint=false',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
context: {
|
||||
client: getClient(),
|
||||
request: {
|
||||
useSsl: true,
|
||||
internalExperimentFlags: [],
|
||||
consistencyTokenJars: [],
|
||||
},
|
||||
user: {
|
||||
lockedSafetyMode: false,
|
||||
},
|
||||
},
|
||||
videoIds,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://music.youtube.com',
|
||||
'Authorization': await getAuthorizationHeader(token),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text) as QueueRendererResponse;
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -1,26 +0,0 @@
|
||||
import {
|
||||
ItemPlaylistPanelVideoRenderer,
|
||||
PlaylistPanelVideoWrapperRenderer,
|
||||
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);
|
||||
93
src/plugins/music-together/src/Button.tsx
Normal file
93
src/plugins/music-together/src/Button.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import { css } from 'solid-styled-components';
|
||||
|
||||
import { useFloating } from 'solid-floating-ui';
|
||||
|
||||
import { autoUpdate, flip, offset } from '@floating-ui/dom';
|
||||
|
||||
import { Portal } from 'solid-js/web';
|
||||
|
||||
import { cacheNoArgs } from '@/providers/decorators';
|
||||
import { MusicTogetherPanel } from '@/plugins/music-together/src/Panel';
|
||||
|
||||
const buttonStyle = cacheNoArgs(
|
||||
() => css`
|
||||
display: inline-flex;
|
||||
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
margin-right: 16px;
|
||||
|
||||
& svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:hover svg:hover {
|
||||
fill: #fff;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const popupStyle = cacheNoArgs(
|
||||
() => css`
|
||||
position: fixed;
|
||||
top: var(--offset-y, 0);
|
||||
left: var(--offset-x, 0);
|
||||
|
||||
z-index: 1000;
|
||||
`,
|
||||
);
|
||||
|
||||
export const MusicTogetherButton = () => {
|
||||
const [enabled, setEnabled] = createSignal(false);
|
||||
|
||||
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
||||
const [panel, setPanel] = createSignal<HTMLElement | null>(null);
|
||||
|
||||
const position = useFloating(anchor, panel, {
|
||||
whileElementsMounted: autoUpdate,
|
||||
strategy: 'fixed',
|
||||
placement: 'bottom-end',
|
||||
middleware: [
|
||||
offset({
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}),
|
||||
flip({ fallbackStrategy: 'bestFit' }),
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id="music-together-setting-button"
|
||||
class={`${buttonStyle()} style-scope ytmusic-nav-bar`}
|
||||
>
|
||||
<svg
|
||||
ref={setAnchor}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 -960 960 960"
|
||||
width="24"
|
||||
onClick={() => setEnabled(!enabled())}
|
||||
>
|
||||
<path d="M0-240v-63q0-43 44-70t116-27q13 0 25 .5t23 2.5q-14 21-21 44t-7 48v65H0Zm240 0v-65q0-32 17.5-58.5T307-410q32-20 76.5-30t96.5-10q53 0 97.5 10t76.5 30q32 20 49 46.5t17 58.5v65H240Zm540 0v-65q0-26-6.5-49T754-397q11-2 22.5-2.5t23.5-.5q72 0 116 26.5t44 70.5v63H780Zm-455-80h311q-10-20-55.5-35T480-370q-55 0-100.5 15T325-320ZM160-440q-33 0-56.5-23.5T80-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T160-440Zm640 0q-33 0-56.5-23.5T720-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T800-440Zm-320-40q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T600-600q0 50-34.5 85T480-480Zm0-80q17 0 28.5-11.5T520-600q0-17-11.5-28.5T480-640q-17 0-28.5 11.5T440-600q0 17 11.5 28.5T480-560Zm1 240Zm-1-280Z" />
|
||||
</svg>
|
||||
<Show when={enabled()}>
|
||||
<Portal>
|
||||
<div
|
||||
ref={setPanel}
|
||||
class={popupStyle()}
|
||||
style={{
|
||||
'--offset-x': `${position.x}px`,
|
||||
'--offset-y': `${position.y}px`,
|
||||
}}
|
||||
>
|
||||
<MusicTogetherPanel />
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
154
src/plugins/music-together/src/Panel.tsx
Normal file
154
src/plugins/music-together/src/Panel.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { css } from 'solid-styled-components';
|
||||
import { createEffect, Match, Switch } from 'solid-js';
|
||||
|
||||
import { PanelItem } from './PanelItem';
|
||||
import { MusicTogetherStatus } from './Status';
|
||||
import {
|
||||
IconConnect,
|
||||
IconKey,
|
||||
IconMusicCast,
|
||||
IconOff,
|
||||
IconTune,
|
||||
} from './icons';
|
||||
|
||||
import { cacheNoArgs } from '@/providers/decorators';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { AppElement } from '@/types/queue';
|
||||
|
||||
import { Host } from '../api/host';
|
||||
import { Guest } from '../api/guest';
|
||||
import { Connection } from '../connection';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { setStatus, status } from '../store/status';
|
||||
import { connection, setConnection } from '../store/connection';
|
||||
import { useRendererContext } from '../context/RendererContext';
|
||||
|
||||
const panelStyle = cacheNoArgs(
|
||||
() => css`
|
||||
border-radius: 10px !important;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
`,
|
||||
);
|
||||
const horizontalDividerStyle = cacheNoArgs(
|
||||
() => css`
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
`,
|
||||
);
|
||||
|
||||
export const MusicTogetherPanel = () => {
|
||||
const show = useToast();
|
||||
const { ipc } = useRendererContext();
|
||||
|
||||
const onHost = async () => {
|
||||
setStatus('mode', 'connecting');
|
||||
const result = new Connection();
|
||||
await result.waitForReady();
|
||||
setStatus('mode', 'host');
|
||||
setConnection(result);
|
||||
|
||||
await onHostCopy();
|
||||
};
|
||||
const onHostCopy = async () => {
|
||||
const id = connection()?.id;
|
||||
|
||||
if (!id) {
|
||||
show(t('plugins.music-together.toast.id-copy-failed'));
|
||||
return;
|
||||
}
|
||||
const success = await navigator.clipboard
|
||||
.writeText(id)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!success) {
|
||||
show(t('plugins.music-together.toast.id-copy-failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
show(t('plugins.music-together.toast.id-copied', { id }));
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setStatus('mode', 'disconnected');
|
||||
connection()?.disconnect();
|
||||
setConnection(null);
|
||||
|
||||
show(t('plugins.music-together.toast.closed'));
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const conn = connection();
|
||||
const mode = status.mode;
|
||||
const app = document.querySelector<AppElement>('ytmusic-app');
|
||||
|
||||
if (conn && app) {
|
||||
if (mode === 'host') {
|
||||
const listener = Host.buildListener(conn, {
|
||||
ipc,
|
||||
app,
|
||||
});
|
||||
conn.on(listener);
|
||||
}
|
||||
if (mode === 'guest') {
|
||||
const listener = Guest.buildListener(conn, {
|
||||
ipc,
|
||||
app,
|
||||
});
|
||||
conn.on(listener);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<tp-yt-paper-listbox
|
||||
class={`style-scope ytmusic-menu-popup-renderer ${panelStyle()}`}
|
||||
>
|
||||
<MusicTogetherStatus />
|
||||
<Switch>
|
||||
<Match when={status.mode === 'disconnected'}>
|
||||
<div class={horizontalDividerStyle()} />
|
||||
<PanelItem
|
||||
text={t('plugins.music-together.menu.host')}
|
||||
icon={<IconMusicCast width={24} height={24} />}
|
||||
onClick={onHost}
|
||||
/>
|
||||
<PanelItem
|
||||
text={t('plugins.music-together.menu.join')}
|
||||
icon={<IconConnect width={24} height={24} />}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={status.mode === 'host'}>
|
||||
<div class={horizontalDividerStyle()} />
|
||||
<PanelItem
|
||||
text={t('plugins.music-together.menu.click-to-copy-id')}
|
||||
icon={<IconKey width={24} height={24} />}
|
||||
onClick={onHostCopy}
|
||||
/>
|
||||
<PanelItem
|
||||
text={t('plugins.music-together.menu.set-permission', {
|
||||
permission: t('plugins.music-together.menu.permission.host-only'),
|
||||
})}
|
||||
icon={<IconTune width={24} height={24} />}
|
||||
/>
|
||||
<div class={horizontalDividerStyle()} />
|
||||
<PanelItem
|
||||
text={t('plugins.music-together.menu.close')}
|
||||
icon={<IconOff width={24} height={24} />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={status.mode === 'guest'}>
|
||||
<div class={horizontalDividerStyle()} />
|
||||
<PanelItem
|
||||
text={t('plugins.music-together.menu.close')}
|
||||
icon={<IconOff width={24} height={24} />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</tp-yt-paper-listbox>
|
||||
);
|
||||
};
|
||||
43
src/plugins/music-together/src/PanelItem.tsx
Normal file
43
src/plugins/music-together/src/PanelItem.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { JSX } from 'solid-js';
|
||||
|
||||
import { css } from 'solid-styled-components';
|
||||
|
||||
import { cacheNoArgs } from '@/providers/decorators';
|
||||
|
||||
const itemStyle = cacheNoArgs(
|
||||
() => css`
|
||||
display: flex;
|
||||
height: 48px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
--iron-icon-fill-color: #fff;
|
||||
|
||||
&:not([is-disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--ytmusic-menu-item-hover-background-color,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
export type PanelItemProps = {
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export const PanelItem = (props: PanelItemProps) => {
|
||||
return (
|
||||
<div class={`style-scope ${itemStyle()}`} onClick={props.onClick}>
|
||||
<div class="icon style-scope ytmusic-menu-service-item-renderer">
|
||||
{props.icon}
|
||||
</div>
|
||||
<div class="text style-scope ytmusic-menu-service-item-renderer">
|
||||
{props.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
211
src/plugins/music-together/src/Status.tsx
Normal file
211
src/plugins/music-together/src/Status.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import { For, Match, Show, Switch } from 'solid-js';
|
||||
import { css } from 'solid-styled-components';
|
||||
|
||||
import { status } from '../store/status';
|
||||
|
||||
import { cacheNoArgs } from '@/providers/decorators';
|
||||
import { user } from '@/plugins/music-together/store/user';
|
||||
|
||||
const panelStyle = cacheNoArgs(
|
||||
() => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
padding: 16px;
|
||||
`,
|
||||
);
|
||||
const containerStyle = cacheNoArgs(
|
||||
() => css`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
`,
|
||||
);
|
||||
const profileStyle = cacheNoArgs(
|
||||
() => css`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
);
|
||||
const itemStyle = cacheNoArgs(
|
||||
() => css`
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
`,
|
||||
);
|
||||
const userContainerStyle = cacheNoArgs(
|
||||
() => css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
font-size: 14px;
|
||||
`,
|
||||
);
|
||||
const emptyStyle = cacheNoArgs(
|
||||
() => css`
|
||||
width: 100%;
|
||||
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
`,
|
||||
);
|
||||
const spinnerContainerStyle = cacheNoArgs(
|
||||
() => css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`,
|
||||
);
|
||||
const horizontalDividerStyle = cacheNoArgs(
|
||||
() => css`
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
`,
|
||||
);
|
||||
|
||||
export const MusicTogetherStatus = () => {
|
||||
return (
|
||||
<div class={panelStyle()}>
|
||||
<div class={containerStyle()}>
|
||||
<img
|
||||
class={profileStyle()}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
}}
|
||||
src={user.thumbnail}
|
||||
alt="Profile Image"
|
||||
/>
|
||||
<div class={itemStyle()}>
|
||||
<ytmd-trans key="plugins.music-together.name" />
|
||||
<span id="music-together-status-label">
|
||||
<Switch>
|
||||
<Match when={status.mode === 'disconnected'}>
|
||||
<ytmd-trans
|
||||
key="plugins.music-together.menu.status.disconnected"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={status.mode === 'host'}>
|
||||
<ytmd-trans
|
||||
key="plugins.music-together.menu.status.host"
|
||||
style={{ color: 'rgba(255, 0, 0, 1)' }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={status.mode === 'guest'}>
|
||||
<ytmd-trans
|
||||
key="plugins.music-together.menu.status.guest"
|
||||
style={{ color: 'rgba(255, 255, 255, 1)' }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={status.mode === 'connecting'}>
|
||||
<ytmd-trans
|
||||
key="plugins.music-together.menu.status.connecting"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
<Show
|
||||
when={
|
||||
status.mode !== 'connecting' && status.mode !== 'disconnected'
|
||||
}
|
||||
>
|
||||
<marquee id="music-together-permission-label">
|
||||
<Switch>
|
||||
<Match when={status.permission === 'all'}>
|
||||
<ytmd-trans
|
||||
key="plugins.music-together.menu.permission.all"
|
||||
style={{ color: 'rgba(255, 255, 255, 1)' }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={status.permission === 'playlist'}>
|
||||
<ytmd-trans
|
||||
key="plugins.music-together.menu.permission.playlist"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.75)' }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={status.permission === 'host-only'}>
|
||||
<ytmd-trans
|
||||
key="plugins.music-together.menu.permission.host-only"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</marquee>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={status.mode !== 'connecting' && status.mode !== 'disconnected'}
|
||||
fallback={
|
||||
<Show when={status.mode === 'connecting'}>
|
||||
<div
|
||||
class={horizontalDividerStyle()}
|
||||
style={{
|
||||
'margin-top': '16px',
|
||||
'margin-bottom': '32px',
|
||||
}}
|
||||
/>
|
||||
<div class={spinnerContainerStyle()}>
|
||||
<tp-yt-paper-spinner-lite
|
||||
active
|
||||
id="music-together-host-spinner"
|
||||
class="loading-indicator style-scope music-together-spinner"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class={horizontalDividerStyle()} style="margin: 16px 0;" />
|
||||
<div class={itemStyle()}>
|
||||
<ytmd-trans
|
||||
key="plugins.music-together.menu.connected-users"
|
||||
attr:count={status.users.length}
|
||||
/>
|
||||
</div>
|
||||
<div class={userContainerStyle()}>
|
||||
<For
|
||||
each={status.users}
|
||||
fallback={
|
||||
<span class={emptyStyle()}>
|
||||
<ytmd-trans key="plugins.music-together.menu.empty-user" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(user) => (
|
||||
<img
|
||||
class={profileStyle()}
|
||||
src={user.thumbnail}
|
||||
title={user.name}
|
||||
alt={`${user.name} (${user.id})`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
src/plugins/music-together/src/icons/IconConnect.tsx
Normal file
7
src/plugins/music-together/src/icons/IconConnect.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { IconProps } from './types';
|
||||
|
||||
export const IconConnect = (props: IconProps) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
|
||||
<path d="M480-640 280-440l56 56 104-103v407h80v-407l104 103 56-56-200-200ZM146-260q-32-49-49-105T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 59-17 115t-49 105l-58-58q22-37 33-78t11-84q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 43 11 84t33 78l-58 58Z" />
|
||||
</svg>
|
||||
);
|
||||
7
src/plugins/music-together/src/icons/IconKey.tsx
Normal file
7
src/plugins/music-together/src/icons/IconKey.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { IconProps } from './types';
|
||||
|
||||
export const IconKey = (props: IconProps) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
|
||||
<path d="M280-400q-33 0-56.5-23.5T200-480q0-33 23.5-56.5T280-560q33 0 56.5 23.5T360-480q0 33-23.5 56.5T280-400Zm0 160q-100 0-170-70T40-480q0-100 70-170t170-70q67 0 121.5 33t86.5 87h352l120 120-180 180-80-60-80 60-85-60h-47q-32 54-86.5 87T280-240Zm0-80q56 0 98.5-34t56.5-86h125l58 41 82-61 71 55 75-75-40-40H435q-14-52-56.5-86T280-640q-66 0-113 47t-47 113q0 66 47 113t113 47Z"/>
|
||||
</svg>
|
||||
);
|
||||
7
src/plugins/music-together/src/icons/IconMusicCast.tsx
Normal file
7
src/plugins/music-together/src/icons/IconMusicCast.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { IconProps } from './types';
|
||||
|
||||
export const IconMusicCast = (props: IconProps) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
|
||||
<path d="M560-160q-66 0-113-47t-47-113q0-66 47-113t113-47q23 0 42.5 5.5T640-458v-342h240v120H720v360q0 66-47 113t-113 47ZM80-320q0-99 38-186.5T221-659q65-65 152.5-103T560-800v80q-82 0-155 31.5t-127.5 86q-54.5 54.5-86 127T160-320H80Zm160 0q0-66 25.5-124.5t69-102Q378-590 436-615t124-25v80q-100 0-170 70t-70 170h-80Z"/>
|
||||
</svg>
|
||||
);
|
||||
7
src/plugins/music-together/src/icons/IconOff.tsx
Normal file
7
src/plugins/music-together/src/icons/IconOff.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { IconProps } from './types';
|
||||
|
||||
export const IconOff = (props: IconProps) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
|
||||
<path d="M792-56 686-160H260q-92 0-156-64T40-380q0-77 47.5-137T210-594q3-8 6-15.5t6-16.5L56-792l56-56 736 736-56 56ZM260-240h346L284-562q-2 11-3 21t-1 21h-20q-58 0-99 41t-41 99q0 58 41 99t99 41Zm185-161Zm419 191-58-56q17-14 25.5-32.5T840-340q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-27 0-52 6.5T380-693l-58-58q35-24 74.5-36.5T480-800q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 39-15 72.5T864-210ZM593-479Z"/>
|
||||
</svg>
|
||||
);
|
||||
7
src/plugins/music-together/src/icons/IconTune.tsx
Normal file
7
src/plugins/music-together/src/icons/IconTune.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { IconProps } from './types';
|
||||
|
||||
export const IconTune = (props: IconProps) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
|
||||
<path d="M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z"/>
|
||||
</svg>
|
||||
);
|
||||
5
src/plugins/music-together/src/icons/index.ts
Normal file
5
src/plugins/music-together/src/icons/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './IconConnect';
|
||||
export * from './IconKey';
|
||||
export * from './IconMusicCast';
|
||||
export * from './IconOff';
|
||||
export * from './IconTune';
|
||||
5
src/plugins/music-together/src/icons/types.ts
Normal file
5
src/plugins/music-together/src/icons/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type IconProps = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
fill?: string;
|
||||
};
|
||||
56
src/plugins/music-together/src/index.tsx
Normal file
56
src/plugins/music-together/src/index.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
import { MusicTogetherButton } from './Button';
|
||||
|
||||
import { AppElement } from '@/types/queue';
|
||||
import { RendererContext } from '@/types/contexts';
|
||||
import { MusicTogetherConfig } from '@/plugins/music-together/types';
|
||||
|
||||
import { ToastProvider } from '../context/ToastContext';
|
||||
import { RendererContextProvider } from '../context/RendererContext';
|
||||
|
||||
import { setUser } from '../store/user';
|
||||
|
||||
export const onRendererLoad = (
|
||||
context: RendererContext<MusicTogetherConfig>,
|
||||
) => {
|
||||
const container = document.createElement('div');
|
||||
const target = document.querySelector<HTMLElement>(
|
||||
'#right-content > ytmusic-settings-button',
|
||||
);
|
||||
const api = document.querySelector<AppElement>('ytmusic-app');
|
||||
|
||||
if (!target) {
|
||||
console.warn('Music Together [renderer]: Cannot inject a button');
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.querySelector<HTMLElement>('tp-yt-paper-icon-button');
|
||||
button?.click();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const thumbnail = target?.querySelector<HTMLImageElement>('img')?.src;
|
||||
const name = document.querySelector('#account-name')?.textContent;
|
||||
|
||||
if (name) {
|
||||
setUser({ name, thumbnail });
|
||||
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
button?.click();
|
||||
|
||||
target?.insertAdjacentElement('beforebegin', container);
|
||||
render(
|
||||
() => (
|
||||
<RendererContextProvider context={context}>
|
||||
<ToastProvider service={api!.toastService}>
|
||||
<MusicTogetherButton />
|
||||
</ToastProvider>
|
||||
</RendererContextProvider>
|
||||
),
|
||||
container,
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}, 1);
|
||||
};
|
||||
7
src/plugins/music-together/store/connection.ts
Normal file
7
src/plugins/music-together/store/connection.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
import { Connection } from '../connection';
|
||||
|
||||
export const [connection, setConnection] = createSignal<Connection | null>(
|
||||
null,
|
||||
);
|
||||
12
src/plugins/music-together/store/queue.ts
Normal file
12
src/plugins/music-together/store/queue.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { createStore } from 'solid-js/store';
|
||||
|
||||
import { VideoData } from '../types';
|
||||
|
||||
export type QueueStoreType = {
|
||||
queue: VideoData[];
|
||||
title: string;
|
||||
};
|
||||
export const [queue, setQueue] = createStore<QueueStoreType>({
|
||||
queue: [],
|
||||
title: '',
|
||||
});
|
||||
28
src/plugins/music-together/store/status.ts
Normal file
28
src/plugins/music-together/store/status.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { createStore } from 'solid-js/store';
|
||||
|
||||
import { ConnectedState, Permission, User } from '../types';
|
||||
|
||||
// export const getDefaultProfile = (
|
||||
// connectionID: string,
|
||||
// id: string = Date.now().toString(36),
|
||||
// ): User => {
|
||||
// const name = `Guest ${id.slice(-6)}`;
|
||||
//
|
||||
// return {
|
||||
// id: connectionID,
|
||||
// handleId: `#music-together:${id}`,
|
||||
// name,
|
||||
// thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`,
|
||||
// };
|
||||
// };
|
||||
|
||||
export type StatusStoreType = {
|
||||
mode: ConnectedState;
|
||||
permission: Permission;
|
||||
users: User[];
|
||||
};
|
||||
export const [status, setStatus] = createStore<StatusStoreType>({
|
||||
mode: 'disconnected',
|
||||
permission: 'all',
|
||||
users: [],
|
||||
});
|
||||
14
src/plugins/music-together/store/user.ts
Normal file
14
src/plugins/music-together/store/user.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createStore } from 'solid-js/store';
|
||||
|
||||
type ClinetUser = {
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
};
|
||||
|
||||
const id = Date.now().toString(36);
|
||||
const name = `Guest ${id.slice(0, 4)}`;
|
||||
const thumbnail = `https://ui-avatars.com/api/?name=${name}&background=random`;
|
||||
export const [user, setUser] = createStore<ClinetUser>({
|
||||
name,
|
||||
thumbnail,
|
||||
});
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
margin-right: 16px;
|
||||
|
||||
& svg {
|
||||
width: 24px;
|
||||
@ -83,42 +84,15 @@
|
||||
}
|
||||
|
||||
.music-together-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
padding: 16px;
|
||||
}
|
||||
.music-together-profile {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.music-together-profile.big {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.music-together-status-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.music-together-status-item {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.music-together-user-container {
|
||||
display: flex;
|
||||
@ -131,11 +105,6 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.music-together-empty {
|
||||
width: 100%;
|
||||
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.music-together-owner {
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
<div class="style-scope music-together-item">
|
||||
<div class="icon style-scope ytmusic-menu-service-item-renderer">
|
||||
<!-- icon -->
|
||||
</div>
|
||||
<div class="text style-scope ytmusic-menu-service-item-renderer">
|
||||
<!-- text -->
|
||||
</div>
|
||||
</div>
|
||||
@ -1,5 +0,0 @@
|
||||
<div class="music-together-popup">
|
||||
<tp-yt-paper-listbox class="style-scope ytmusic-menu-popup-renderer music-together-popup-container">
|
||||
|
||||
</tp-yt-paper-listbox>
|
||||
</div>
|
||||
@ -1,7 +0,0 @@
|
||||
<div id="music-together-setting-button" class="music-together-button style-scope ytmusic-nav-bar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M0-240v-63q0-43 44-70t116-27q13 0 25 .5t23 2.5q-14 21-21 44t-7 48v65H0Zm240 0v-65q0-32 17.5-58.5T307-410q32-20 76.5-30t96.5-10q53 0 97.5 10t76.5 30q32 20 49 46.5t17 58.5v65H240Zm540 0v-65q0-26-6.5-49T754-397q11-2 22.5-2.5t23.5-.5q72 0 116 26.5t44 70.5v63H780Zm-455-80h311q-10-20-55.5-35T480-370q-55 0-100.5 15T325-320ZM160-440q-33 0-56.5-23.5T80-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T160-440Zm640 0q-33 0-56.5-23.5T720-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T800-440Zm-320-40q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T600-600q0 50-34.5 85T480-480Zm0-80q17 0 28.5-11.5T520-600q0-17-11.5-28.5T480-640q-17 0-28.5 11.5T440-600q0 17 11.5 28.5T480-560Zm1 240Zm-1-280Z"/>
|
||||
</svg>
|
||||
<tp-yt-paper-spinner-lite id="music-together-host-spinner" hidden class="loading-indicator style-scope music-together-spinner"></tp-yt-paper-spinner-lite>
|
||||
</div>
|
||||
<div class="music-together-divider"></div>
|
||||
@ -1,23 +0,0 @@
|
||||
<div class="music-together-status">
|
||||
<div class="music-together-status-container">
|
||||
<img class="music-together-profile big" alt="Profile Image">
|
||||
<div class="music-together-status-item">
|
||||
<ytmd-trans key="plugins.music-together.name"></ytmd-trans>
|
||||
<span id="music-together-status-label">
|
||||
<ytmd-trans key="plugins.music-together.menu.status.disconnected"></ytmd-trans>
|
||||
</span>
|
||||
<marquee id="music-together-permission-label">
|
||||
<ytmd-trans key="plugins.music-together.menu.permission.playlist" style="color: rgba(255, 255, 255, 0.75)"></ytmd-trans>
|
||||
</marquee>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-together-divider horizontal" style="margin: 16px 0;"></div>
|
||||
<div class="music-together-status-item">
|
||||
<ytmd-trans key="plugins.music-together.menu.connected-users"></ytmd-trans>
|
||||
</div>
|
||||
<div class="music-together-user-container">
|
||||
<span class="music-together-empty">
|
||||
<ytmd-trans key="plugins.music-together.menu.empty-user"></ytmd-trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,4 +1,7 @@
|
||||
export type Profile = {
|
||||
export type MusicTogetherConfig = {
|
||||
enabled: boolean;
|
||||
};
|
||||
export type User = {
|
||||
id: string;
|
||||
handleId: string;
|
||||
name: string;
|
||||
@ -8,18 +11,25 @@ export type VideoData = {
|
||||
videoId: string;
|
||||
ownerId: string;
|
||||
};
|
||||
export type ConnectedState = 'disconnected' | 'host' | 'guest' | 'connecting';
|
||||
export type Permission = 'host-only' | 'playlist' | 'all';
|
||||
|
||||
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`,
|
||||
};
|
||||
export type ConnectionEventMap = {
|
||||
ADD_SONGS: { videoList: VideoData[]; index?: number };
|
||||
REMOVE_SONG: { index: number };
|
||||
MOVE_SONG: { fromIndex: number; toIndex: number };
|
||||
IDENTIFY: { user: User } | undefined;
|
||||
SYNC_USER: { users: User[] } | undefined;
|
||||
SYNC_QUEUE: { videoList: VideoData[] } | undefined;
|
||||
SYNC_PROGRESS:
|
||||
| { progress?: number; state?: number; index?: number }
|
||||
| undefined;
|
||||
PERMISSION: Permission | undefined;
|
||||
};
|
||||
export type ConnectionEventUnion = {
|
||||
[Event in keyof ConnectionEventMap]: {
|
||||
type: Event;
|
||||
payload: ConnectionEventMap[Event];
|
||||
after?: ConnectionEventUnion[];
|
||||
};
|
||||
}[keyof ConnectionEventMap];
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { Popup } from '../element';
|
||||
import { createStatus } from '../ui/status';
|
||||
|
||||
import IconOff from '../icons/off.svg?raw';
|
||||
|
||||
export type GuestPopupProps = {
|
||||
onItemClick: (id: string) => void;
|
||||
};
|
||||
export const createGuestPopup = (props: GuestPopupProps) => {
|
||||
const status = createStatus();
|
||||
status.setStatus('guest');
|
||||
|
||||
const result = Popup({
|
||||
data: [
|
||||
{
|
||||
type: 'custom',
|
||||
element: status.element,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
id: 'music-together-disconnect',
|
||||
icon: ElementFromHtml(IconOff),
|
||||
text: t('plugins.music-together.menu.disconnect'),
|
||||
onClick: () => props.onItemClick('music-together-disconnect'),
|
||||
},
|
||||
],
|
||||
anchorAt: 'bottom-right',
|
||||
popupAt: 'top-right',
|
||||
});
|
||||
|
||||
return {
|
||||
...status,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
@ -1,62 +0,0 @@
|
||||
import { t } from '@/i18n';
|
||||
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||
|
||||
import { Popup } from '../element';
|
||||
import { createStatus } from '../ui/status';
|
||||
|
||||
import IconKey from '../icons/key.svg?raw';
|
||||
import IconOff from '../icons/off.svg?raw';
|
||||
import IconTune from '../icons/tune.svg?raw';
|
||||
|
||||
export type HostPopupProps = {
|
||||
onItemClick: (id: string) => void;
|
||||
};
|
||||
export const createHostPopup = (props: HostPopupProps) => {
|
||||
const status = createStatus();
|
||||
status.setStatus('host');
|
||||
|
||||
const result = Popup({
|
||||
data: [
|
||||
{
|
||||
type: 'custom',
|
||||
element: status.element,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'music-together-copy-id',
|
||||
type: 'item',
|
||||
icon: ElementFromHtml(IconKey),
|
||||
text: t('plugins.music-together.menu.click-to-copy-id'),
|
||||
onClick: () => props.onItemClick('music-together-copy-id'),
|
||||
},
|
||||
{
|
||||
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'),
|
||||
}),
|
||||
onClick: () => props.onItemClick('music-together-permission'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
id: 'music-together-close',
|
||||
icon: ElementFromHtml(IconOff),
|
||||
text: t('plugins.music-together.menu.close'),
|
||||
onClick: () => props.onItemClick('music-together-close'),
|
||||
},
|
||||
],
|
||||
anchorAt: 'bottom-right',
|
||||
popupAt: 'top-right',
|
||||
});
|
||||
|
||||
return {
|
||||
...status,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
@ -1,49 +0,0 @@
|
||||
import { Popup } from '@/plugins/music-together/element';
|
||||
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||
|
||||
import { createStatus } from './status';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import IconMusicCast from '../icons/music-cast.svg?raw';
|
||||
import IconConnect from '../icons/connect.svg?raw';
|
||||
|
||||
export type SettingPopupProps = {
|
||||
onItemClick: (id: string) => void;
|
||||
};
|
||||
export const createSettingPopup = (props: SettingPopupProps) => {
|
||||
const status = createStatus();
|
||||
status.setStatus('disconnected');
|
||||
|
||||
const result = Popup({
|
||||
data: [
|
||||
{
|
||||
type: 'custom',
|
||||
element: status.element,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'music-together-host',
|
||||
type: 'item',
|
||||
icon: ElementFromHtml(IconMusicCast),
|
||||
text: t('plugins.music-together.menu.host'),
|
||||
onClick: () => props.onItemClick('music-together-host'),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
icon: ElementFromHtml(IconConnect),
|
||||
text: t('plugins.music-together.menu.join'),
|
||||
onClick: () => props.onItemClick('music-together-join'),
|
||||
},
|
||||
],
|
||||
anchorAt: 'bottom-right',
|
||||
popupAt: 'top-right',
|
||||
});
|
||||
|
||||
return {
|
||||
...status,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
@ -1,102 +0,0 @@
|
||||
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import statusHTML from '../templates/status.html?raw';
|
||||
|
||||
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 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.style.color = 'rgba(255, 255, 255, 0.5)';
|
||||
}
|
||||
|
||||
if (status === 'host') {
|
||||
statusLabel.textContent = t('plugins.music-together.menu.status.host');
|
||||
statusLabel.style.color = 'rgba(255, 0, 0, 1)';
|
||||
}
|
||||
|
||||
if (status === 'guest') {
|
||||
statusLabel.textContent = t('plugins.music-together.menu.status.guest');
|
||||
statusLabel.style.color = 'rgba(255, 255, 255, 1)';
|
||||
}
|
||||
};
|
||||
|
||||
const setPermission = (permission: Permission) => {
|
||||
if (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.style.color = 'rgba(255, 255, 255, 0.75)';
|
||||
}
|
||||
|
||||
if (permission === 'all') {
|
||||
permissionLabel.textContent = t(
|
||||
'plugins.music-together.menu.permission.all',
|
||||
);
|
||||
permissionLabel.style.color = 'rgba(255, 255, 255, 1)';
|
||||
}
|
||||
};
|
||||
|
||||
const setProfile = (src: string) => {
|
||||
profile.src = src;
|
||||
};
|
||||
|
||||
const setUsers = (users: Profile[]) => {
|
||||
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();
|
||||
}
|
||||
|
||||
if (users.length === 0) empty.style.display = 'block';
|
||||
else empty.style.display = 'none';
|
||||
|
||||
for (const user of users) {
|
||||
const img = document.createElement('img');
|
||||
img.classList.add('music-together-profile');
|
||||
img.src = user.thumbnail ?? '';
|
||||
img.title = user.name;
|
||||
img.alt = `${user.name} (${user.id})`;
|
||||
|
||||
container.append(img);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
element,
|
||||
setStatus,
|
||||
setUsers,
|
||||
setProfile,
|
||||
setPermission,
|
||||
};
|
||||
};
|
||||
@ -24,16 +24,6 @@ const parseBooleanFromArgsType = (args: ArgsType<boolean>) => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseStringFromArgsType = (args: ArgsType<string>) => {
|
||||
if (typeof args === 'string') {
|
||||
return args;
|
||||
} else if (Array.isArray(args)) {
|
||||
return args[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default (win: BrowserWindow) => {
|
||||
return {
|
||||
// Playback
|
||||
@ -100,15 +90,14 @@ export default (win: BrowserWindow) => {
|
||||
});
|
||||
},
|
||||
// Queue
|
||||
addSongToQueue: (videoId: string, queueInsertPosition: string) => {
|
||||
const videoIdValue = parseStringFromArgsType(videoId);
|
||||
if (videoIdValue === null) return;
|
||||
|
||||
win.webContents.send(
|
||||
'ytmd:add-to-queue',
|
||||
videoIdValue,
|
||||
queueInsertPosition,
|
||||
);
|
||||
addSongToQueue: (
|
||||
videoIds: string | string[],
|
||||
options: {
|
||||
queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO';
|
||||
index?: number;
|
||||
},
|
||||
) => {
|
||||
win.webContents.send('ytmd:add-to-queue', videoIds, options);
|
||||
},
|
||||
moveSongInQueue: (
|
||||
fromIndex: ArgsType<number>,
|
||||
|
||||
@ -171,21 +171,27 @@ async function onApiLoaded() {
|
||||
} satisfies QueueResponse);
|
||||
});
|
||||
|
||||
type AddToQueueOptions = {
|
||||
queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO';
|
||||
index?: number;
|
||||
};
|
||||
window.ipcRenderer.on(
|
||||
'ytmd:add-to-queue',
|
||||
(_, videoId: string, queueInsertPosition: string) => {
|
||||
(_, videoIds: string | string[], { queueInsertPosition = 'INSERT_AT_END', index }: AddToQueueOptions) => {
|
||||
const ids = Array.isArray(videoIds) ? videoIds : [videoIds];
|
||||
const queue = document.querySelector<QueueElement>('#queue');
|
||||
const app = document.querySelector<YouTubeMusicAppElement>('ytmusic-app');
|
||||
if (!app) return;
|
||||
|
||||
const store = queue?.queue.store.store;
|
||||
console.log('add-to-queue!', ids, queue, app, store);
|
||||
if (!store) return;
|
||||
|
||||
app.networkManager
|
||||
.fetch('/music/get_queue', {
|
||||
queueContextParams: store.getState().queue.queueContextParams,
|
||||
queueInsertPosition,
|
||||
videoIds: [videoId],
|
||||
videoIds: ids,
|
||||
})
|
||||
.then((result) => {
|
||||
if (
|
||||
@ -201,7 +207,8 @@ async function onApiLoaded() {
|
||||
payload: {
|
||||
nextQueueItemId: store.getState().queue.nextQueueItemId,
|
||||
index:
|
||||
queueInsertPosition === 'INSERT_AFTER_CURRENT_VIDEO'
|
||||
(index ??
|
||||
queueInsertPosition === 'INSERT_AFTER_CURRENT_VIDEO')
|
||||
? queueItems.findIndex(
|
||||
(it) =>
|
||||
(
|
||||
@ -383,8 +390,14 @@ const defineYTMDTransElements = () => {
|
||||
YTMDTrans.prototype.connectedCallback = function () {
|
||||
const that = this as HTMLElement;
|
||||
const key = that.getAttribute('key');
|
||||
const options: Record<string, unknown> = {};
|
||||
that.getAttributeNames().forEach((attr) => {
|
||||
if (attr === 'key') return;
|
||||
|
||||
options[attr] = that.getAttribute(attr);
|
||||
});
|
||||
if (key) {
|
||||
const targetHtml = i18t(key);
|
||||
const targetHtml = i18t(key, options);
|
||||
(that.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy
|
||||
? defaultTrustedTypePolicy.createHTML(targetHtml)
|
||||
: targetHtml;
|
||||
|
||||
11
src/yt-web-components.d.ts
vendored
11
src/yt-web-components.d.ts
vendored
@ -42,6 +42,17 @@ declare module 'solid-js' {
|
||||
YpYtPaperSpinnerLiteProps;
|
||||
'tp-yt-paper-icon-button': ComponentProps<'div'> &
|
||||
TpYtPaperIconButtonProps;
|
||||
'tp-yt-paper-listbox': ComponentProps<'div'>;
|
||||
|
||||
// Non-ytmusic elements
|
||||
'ytmd-trans': ComponentProps<'span'> & {
|
||||
key: string;
|
||||
} & {
|
||||
[key: `attr:${strig}`]: unknown;
|
||||
};
|
||||
|
||||
// fallback
|
||||
'marquee': ComponentProps<'marquee'>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user