Compare commits

...

2 Commits

54 changed files with 1190 additions and 2081 deletions

View File

@ -2,8 +2,61 @@
All notable changes to this project will be documented in this file. Dates are displayed in UTC. All notable changes to this project will be documented in this file. Dates are displayed in UTC.
#### [v3.7.3](https://github.com/th-ch/youtube-music/compare/v3.7.2...v3.7.3)
- fix(downloader): use the upgrade button to check for premium status [`#2987`](https://github.com/th-ch/youtube-music/pull/2987)
- chore(deps): update dependency electron-vite to v3 [`#2986`](https://github.com/th-ch/youtube-music/pull/2986)
- chore(deps): update dependency @babel/runtime to v7.26.9 [`#2980`](https://github.com/th-ch/youtube-music/pull/2980)
- fix(vite): set server.cors.origin [`#2981`](https://github.com/th-ch/youtube-music/pull/2981)
- chore(deps-dev): bump esbuild from 0.24.2 to 0.25.0 [`#2973`](https://github.com/th-ch/youtube-music/pull/2973)
- fix(deps): update dependency solid-transition-group to v0.3.0 [`#2949`](https://github.com/th-ch/youtube-music/pull/2949)
- fix: remove disable-gpu-memory-buffer-video-frames flag [`#2963`](https://github.com/th-ch/youtube-music/pull/2963)
- fix(deps): update dependency semver to v7.7.0 [`#2948`](https://github.com/th-ch/youtube-music/pull/2948)
- chore(deps): update playwright monorepo to v1.50.1 [`#2943`](https://github.com/th-ch/youtube-music/pull/2943)
- fix(deps): update dependency @hono/node-server to v1.13.8 [`#2944`](https://github.com/th-ch/youtube-music/pull/2944)
- fix(deps): update dependency electron-store to v10.0.1 [`#2945`](https://github.com/th-ch/youtube-music/pull/2945)
- chore(deps): update dependency rollup to v4.34.1 [`#2946`](https://github.com/th-ch/youtube-music/pull/2946)
- chore(deps): update dependency typescript-eslint to v8.22.0 [`#2947`](https://github.com/th-ch/youtube-music/pull/2947)
- fix(synced-lyrics): Fix reverse direction of synced lyrics for persian or other rtl languages [`#2940`](https://github.com/th-ch/youtube-music/pull/2940)
- chore(deps): update dependency electron to v34.0.2 [`#2942`](https://github.com/th-ch/youtube-music/pull/2942)
- chore(deps): update dependency discord-api-types to v0.37.119 [`#2941`](https://github.com/th-ch/youtube-music/pull/2941)
- fix(deps): update dependency hono to v4.6.20 [`#2932`](https://github.com/th-ch/youtube-music/pull/2932)
- chore(deps): update eslint monorepo to v9.19.0 [`#2935`](https://github.com/th-ch/youtube-music/pull/2935)
- fix(deps): update dependency bgutils-js to v3.1.3 [`#2934`](https://github.com/th-ch/youtube-music/pull/2934)
- fix(deps): update dependency i18next to v24.2.2 [`#2933`](https://github.com/th-ch/youtube-music/pull/2933)
- fix(deps): update dependency happy-dom to v16.8.1 [`#2936`](https://github.com/th-ch/youtube-music/pull/2936)
- chore(deps): update dependency @babel/runtime to v7.26.7 [`#2924`](https://github.com/th-ch/youtube-music/pull/2924)
- chore(config): migrate renovate config [`#2925`](https://github.com/th-ch/youtube-music/pull/2925)
- fix(deps): update dependency @ghostery/adblocker-electron-preload to v2.5.0 [`#2923`](https://github.com/th-ch/youtube-music/pull/2923)
- fix(deps): update dependency @ghostery/adblocker-electron to v2.5.0 [`#2922`](https://github.com/th-ch/youtube-music/pull/2922)
- chore(deps): update playwright monorepo to v1.50.0 [`#2921`](https://github.com/th-ch/youtube-music/pull/2921)
- chore(deps): update dependency vite-plugin-inspect to v10.1.0 [`#2920`](https://github.com/th-ch/youtube-music/pull/2920)
- chore(deps): update dependency rollup to v4.32.0 [`#2919`](https://github.com/th-ch/youtube-music/pull/2919)
- fix(deps): update dependency hono to v4.6.18 [`#2918`](https://github.com/th-ch/youtube-music/pull/2918)
- fix(deps): update dependency deepmerge-ts to v7.1.4 [`#2917`](https://github.com/th-ch/youtube-music/pull/2917)
- chore(deps): update dependency vite to v6.0.11 [`#2894`](https://github.com/th-ch/youtube-music/pull/2894)
- chore(deps): update dependency electron to v34.0.1 [`#2916`](https://github.com/th-ch/youtube-music/pull/2916)
- chore(deps): update dependency electron-builder-squirrel-windows to v26.0.0-alpha.10 [`#2899`](https://github.com/th-ch/youtube-music/pull/2899)
- chore(deps): update dependency electron-builder to v26.0.0-alpha.10 [`#2898`](https://github.com/th-ch/youtube-music/pull/2898)
- chore(deps): update dependency typescript-eslint to v8.21.0 [`#2901`](https://github.com/th-ch/youtube-music/pull/2901)
- chore(deps): update dependency discord-api-types to v0.37.117 [`#2895`](https://github.com/th-ch/youtube-music/pull/2895)
- fix(deps): update dependency youtubei.js to v13 [`#2904`](https://github.com/th-ch/youtube-music/pull/2904)
- chore(deps): update dependency vite to v6.0.9 [security] [`#2907`](https://github.com/th-ch/youtube-music/pull/2907)
- fix(deps): update dependency happy-dom to v16.7.2 [`#2902`](https://github.com/th-ch/youtube-music/pull/2902)
- fix(discord-plugin): handle album name padding if length < 2 [`#2903`](https://github.com/th-ch/youtube-music/pull/2903)
- feat(navigation): added nav icon padding [`#2905`](https://github.com/th-ch/youtube-music/pull/2905)
- chore(deps): update dependency rollup to v4.31.0 [`#2891`](https://github.com/th-ch/youtube-music/pull/2891)
- chore(deps): update dependency eslint-plugin-prettier to v5.2.3 [`#2889`](https://github.com/th-ch/youtube-music/pull/2889)
- chore(deps): update dependency vite-plugin-inspect to v10.0.7 [`#2882`](https://github.com/th-ch/youtube-music/pull/2882)
- fix(deps): update dependency hono to v4.6.17 [`#2883`](https://github.com/th-ch/youtube-music/pull/2883)
- fix: bump deps [`e9184e5`](https://github.com/th-ch/youtube-music/commit/e9184e5d60c2495473a7c3226ce9748ba89fceb3)
- fix(deps): fix pnpm [`040db75`](https://github.com/th-ch/youtube-music/commit/040db7539ccd1ae40f2632fdf38168cdaa26f112)
- chore(i18n): Translated using Weblate (Persian) [`9d18587`](https://github.com/th-ch/youtube-music/commit/9d185872dba5b56dabc691e56eafb13dc192b9cd)
#### [v3.7.2](https://github.com/th-ch/youtube-music/compare/v3.7.1...v3.7.2) #### [v3.7.2](https://github.com/th-ch/youtube-music/compare/v3.7.1...v3.7.2)
> 18 January 2025
- feat(api-server): add endpoint to get shuffle state [`#2792`](https://github.com/th-ch/youtube-music/pull/2792) - feat(api-server): add endpoint to get shuffle state [`#2792`](https://github.com/th-ch/youtube-music/pull/2792)
- chore(deps): update dependency discord-api-types to v0.37.116 [`#2877`](https://github.com/th-ch/youtube-music/pull/2877) - chore(deps): update dependency discord-api-types to v0.37.116 [`#2877`](https://github.com/th-ch/youtube-music/pull/2877)
- chore(deps): update dependency eslint-plugin-prettier to v5.2.2 [`#2875`](https://github.com/th-ch/youtube-music/pull/2875) - chore(deps): update dependency eslint-plugin-prettier to v5.2.2 [`#2875`](https://github.com/th-ch/youtube-music/pull/2875)

22
pnpm-lock.yaml generated
View File

@ -14,13 +14,13 @@ overrides:
patchedDependencies: patchedDependencies:
'@malept/flatpak-bundler': '@malept/flatpak-bundler':
hash: c787371eeb2af011ea934e8818a0dad6d7dcb2df31bbb1686babc7231af0183c hash: vli4xtu6rndz3xtsscixhngsxa
path: patches/@malept__flatpak-bundler.patch path: patches/@malept__flatpak-bundler.patch
app-builder-lib@26.0.6: app-builder-lib@26.0.6:
hash: 1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f hash: zcnm2qnjaggm2keyecnhiglkke
path: patches/app-builder-lib@26.0.6.patch path: patches/app-builder-lib@26.0.6.patch
vudio@2.1.1: vudio@2.1.1:
hash: 0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d hash: 7iux5msqpgl3octdmwy4uspwoe
path: patches/vudio@2.1.1.patch path: patches/vudio@2.1.1.patch
importers: importers:
@ -191,7 +191,7 @@ importers:
version: 25.0.1 version: 25.0.1
vudio: vudio:
specifier: 2.1.1 specifier: 2.1.1
version: 2.1.1(patch_hash=0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d) version: 2.1.1(patch_hash=7iux5msqpgl3octdmwy4uspwoe)
x11: x11:
specifier: 2.3.0 specifier: 2.3.0
version: 2.3.0 version: 2.3.0
@ -5365,7 +5365,7 @@ snapshots:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
'@malept/flatpak-bundler@0.4.0(patch_hash=c787371eeb2af011ea934e8818a0dad6d7dcb2df31bbb1686babc7231af0183c)': '@malept/flatpak-bundler@0.4.0(patch_hash=vli4xtu6rndz3xtsscixhngsxa)':
dependencies: dependencies:
debug: 4.4.0 debug: 4.4.0
fs-extra: 9.1.0 fs-extra: 9.1.0
@ -5832,7 +5832,7 @@ snapshots:
app-builder-bin@5.0.0-alpha.12: {} 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: dependencies:
'@develar/schema-utils': 2.6.5 '@develar/schema-utils': 2.6.5
'@electron/asar': 3.2.18 '@electron/asar': 3.2.18
@ -5841,7 +5841,7 @@ snapshots:
'@electron/osx-sign': 1.3.1 '@electron/osx-sign': 1.3.1
'@electron/rebuild': 3.7.0 '@electron/rebuild': 3.7.0
'@electron/universal': 2.0.1 '@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 '@types/fs-extra': 9.0.13
async-exit-hook: 2.0.1 async-exit-hook: 2.0.1
builder-util: 26.0.4 builder-util: 26.0.4
@ -6464,7 +6464,7 @@ snapshots:
dmg-builder@26.0.6(electron-builder-squirrel-windows@26.0.6): dmg-builder@26.0.6(electron-builder-squirrel-windows@26.0.6):
dependencies: 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: 26.0.4
builder-util-runtime: 9.3.1 builder-util-runtime: 9.3.1
fs-extra: 10.1.0 fs-extra: 10.1.0
@ -6541,7 +6541,7 @@ snapshots:
electron-builder-squirrel-windows@26.0.6(dmg-builder@26.0.6): electron-builder-squirrel-windows@26.0.6(dmg-builder@26.0.6):
dependencies: 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: 26.0.4
electron-winstaller: 5.4.0 electron-winstaller: 5.4.0
transitivePeerDependencies: transitivePeerDependencies:
@ -6551,7 +6551,7 @@ snapshots:
electron-builder@26.0.6(electron-builder-squirrel-windows@26.0.6): electron-builder@26.0.6(electron-builder-squirrel-windows@26.0.6):
dependencies: 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: 26.0.4
builder-util-runtime: 9.3.1 builder-util-runtime: 9.3.1
chalk: 4.1.2 chalk: 4.1.2
@ -9132,7 +9132,7 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 6.1.0(@types/node@22.13.4)(yaml@2.7.0) 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: wcwidth@1.0.1:
dependencies: dependencies:

View File

@ -541,7 +541,7 @@
"menu": { "menu": {
"click-to-copy-id": "호스트 아이디 복사", "click-to-copy-id": "호스트 아이디 복사",
"close": "Music Together 닫기", "close": "Music Together 닫기",
"connected-users": "연결된 사용자", "connected-users": "연결된 사용자: {{count}}명",
"disconnect": "Music Together 연결 끊기", "disconnect": "Music Together 연결 끊기",
"empty-user": "연결된 사용자 없음", "empty-user": "연결된 사용자 없음",
"host": "Music Together 호스트", "host": "Music Together 호스트",

View File

@ -720,7 +720,7 @@ export const register = (
app.openapi(routes.addSongToQueue, (ctx) => { app.openapi(routes.addSongToQueue, (ctx) => {
const { videoId, insertPosition } = ctx.req.valid('json'); const { videoId, insertPosition } = ctx.req.valid('json');
controller.addSongToQueue(videoId, insertPosition); controller.addSongToQueue(videoId, { queueInsertPosition: insertPosition });
ctx.status(204); ctx.status(204);
return ctx.body(null); return ctx.body(null);

View 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;
},
};

View 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;
},
};

View File

View 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),
);
};

View File

@ -1,26 +1,6 @@
import { DataConnection, Peer } from 'peerjs'; import { DataConnection, Peer } from 'peerjs';
import type { Permission, Profile, VideoData } from './types'; import { ConnectedState, ConnectionEventMap, ConnectionEventUnion } 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];
type PromiseUtil<T> = { type PromiseUtil<T> = {
promise: Promise<T>; promise: Promise<T>;
@ -32,10 +12,10 @@ export type ConnectionListener = (
event: ConnectionEventUnion, event: ConnectionEventUnion,
conn: DataConnection, conn: DataConnection,
) => void; ) => void;
export type ConnectionMode = 'host' | 'guest' | 'disconnected';
export class Connection { export class Connection {
private peer: Peer; private peer: Peer;
private _mode: ConnectionMode = 'disconnected'; private _state: ConnectedState = 'disconnected';
private connections: Record<string, DataConnection> = {}; private connections: Record<string, DataConnection> = {};
private waitOpen: PromiseUtil<string> = {} as PromiseUtil<string>; private waitOpen: PromiseUtil<string> = {} as PromiseUtil<string>;
@ -51,15 +31,15 @@ export class Connection {
}); });
this.peer.on('open', (id) => { this.peer.on('open', (id) => {
this._mode = 'host'; this._state = 'connecting';
this.waitOpen.resolve(id); this.waitOpen.resolve(id);
}); });
this.peer.on('connection', (conn) => { this.peer.on('connection', (conn) => {
this._mode = 'host'; this._state = 'host';
this.registerConnection(conn); this.registerConnection(conn);
}); });
this.peer.on('error', (err) => { this.peer.on('error', (err) => {
this._mode = 'disconnected'; this._state = 'disconnected';
this.waitOpen.reject(err); this.waitOpen.reject(err);
this.connectionListeners.forEach((listener) => listener()); this.connectionListeners.forEach((listener) => listener());
@ -73,16 +53,16 @@ export class Connection {
} }
async connect(id: string) { async connect(id: string) {
this._mode = 'guest'; this._state = 'guest';
const conn = this.peer.connect(id); const conn = this.peer.connect(id);
await this.registerConnection(conn); await this.registerConnection(conn);
return conn; return conn;
} }
disconnect() { 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.connections = {};
this.peer.destroy(); this.peer.destroy();
} }
@ -92,8 +72,8 @@ export class Connection {
return this.peer.id; return this.peer.id;
} }
public get mode() { public get state() {
return this._mode; return this._state;
} }
public getConnections() { public getConnections() {
@ -121,7 +101,7 @@ export class Connection {
private async registerConnection(conn: DataConnection) { private async registerConnection(conn: DataConnection) {
return new Promise<DataConnection>((resolve, reject) => { return new Promise<DataConnection>((resolve, reject) => {
this.peer.once('error', (err) => { this.peer.once('error', (err) => {
this._mode = 'disconnected'; this._state = 'disconnected';
reject(err); reject(err);
this.connectionListeners.forEach((listener) => listener()); this.connectionListeners.forEach((listener) => listener());
@ -133,6 +113,12 @@ export class Connection {
this.connectionListeners.forEach((listener) => listener(conn)); this.connectionListeners.forEach((listener) => listener(conn));
conn.on('data', (data) => { conn.on('data', (data) => {
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch {}
}
if ( if (
!data || !data ||
typeof data !== 'object' || typeof data !== 'object' ||
@ -140,7 +126,7 @@ export class Connection {
!('payload' in data) || !('payload' in data) ||
!data.type !data.type
) { ) {
console.warn('Music Together: Invalid data', data); console.warn('Music Together: Invalid data', data, typeof data);
return; return;
} }

View 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',
};

View 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;
};

View 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);
};
};

View File

@ -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,
};
};

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,84 +1,10 @@
import prompt from 'custom-electron-prompt';
import { DataConnection } from 'peerjs';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import promptOptions from '@/providers/prompt-options';
import { import { onMainLoad } from './backend';
getDefaultProfile, import { onRendererLoad } from './src';
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 settingHTML from './templates/setting.html?raw'; export default createPlugin({
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;
}
>({
name: () => t('plugins.music-together.name'), name: () => t('plugins.music-together.name'),
description: () => t('plugins.music-together.description'), description: () => t('plugins.music-together.description'),
restartNeeded: false, restartNeeded: false,
@ -86,757 +12,9 @@ export default createPlugin<
config: { config: {
enabled: false, enabled: false,
}, },
stylesheets: [style], stylesheets: [],
backend({ ipc }) { backend: onMainLoad,
ipc.handle('music-together:prompt', async (title: string, label: string) =>
prompt({
title,
label,
type: 'input',
...promptOptions(),
}),
);
},
renderer: { renderer: {
updateNext: false, start: onRendererLoad,
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,
);
},
}, },
}); });

View File

@ -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(),
};
};

View File

@ -1 +0,0 @@
export * from './queue';

View File

@ -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));
}
}

View File

@ -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('');
};

View File

@ -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;
};

View File

@ -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);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);

View 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>
);

View 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>
);

View 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>
);

View 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>
);

View File

@ -0,0 +1,5 @@
export * from './IconConnect';
export * from './IconKey';
export * from './IconMusicCast';
export * from './IconOff';
export * from './IconTune';

View File

@ -0,0 +1,5 @@
export type IconProps = {
width?: number;
height?: number;
fill?: string;
};

View 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);
};

View File

@ -0,0 +1,7 @@
import { createSignal } from 'solid-js';
import { Connection } from '../connection';
export const [connection, setConnection] = createSignal<Connection | null>(
null,
);

View 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: '',
});

View 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: [],
});

View 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,
});

View File

@ -3,6 +3,7 @@
cursor: pointer; cursor: pointer;
margin-left: 8px; margin-left: 8px;
margin-right: 16px;
& svg { & svg {
width: 24px; width: 24px;
@ -83,42 +84,15 @@
} }
.music-together-status { .music-together-status {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 16px;
} }
.music-together-profile { .music-together-profile {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
} }
.music-together-profile.big { .music-together-profile.big {
width: 32px;
height: 32px;
} }
.music-together-status-container { .music-together-status-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 16px;
} }
.music-together-status-item { .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 { .music-together-user-container {
display: flex; display: flex;
@ -131,11 +105,6 @@
font-size: 14px; font-size: 14px;
} }
.music-together-empty { .music-together-empty {
width: 100%;
font-size: 14px;
color: rgba(255, 255, 255, .5);
text-align: center;
} }
.music-together-owner { .music-together-owner {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,4 +1,7 @@
export type Profile = { export type MusicTogetherConfig = {
enabled: boolean;
};
export type User = {
id: string; id: string;
handleId: string; handleId: string;
name: string; name: string;
@ -8,18 +11,25 @@ export type VideoData = {
videoId: string; videoId: string;
ownerId: string; ownerId: string;
}; };
export type ConnectedState = 'disconnected' | 'host' | 'guest' | 'connecting';
export type Permission = 'host-only' | 'playlist' | 'all'; export type Permission = 'host-only' | 'playlist' | 'all';
export const getDefaultProfile = ( export type ConnectionEventMap = {
connectionID: string, ADD_SONGS: { videoList: VideoData[]; index?: number };
id: string = Date.now().toString(), REMOVE_SONG: { index: number };
): Profile => { MOVE_SONG: { fromIndex: number; toIndex: number };
const name = `Guest ${id.slice(0, 4)}`; IDENTIFY: { user: User } | undefined;
SYNC_USER: { users: User[] } | undefined;
return { SYNC_QUEUE: { videoList: VideoData[] } | undefined;
id: connectionID, SYNC_PROGRESS:
handleId: `#music-together:${id}`, | { progress?: number; state?: number; index?: number }
name, | undefined;
thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`, PERMISSION: Permission | undefined;
};
}; };
export type ConnectionEventUnion = {
[Event in keyof ConnectionEventMap]: {
type: Event;
payload: ConnectionEventMap[Event];
after?: ConnectionEventUnion[];
};
}[keyof ConnectionEventMap];

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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) => { export default (win: BrowserWindow) => {
return { return {
// Playback // Playback
@ -100,15 +90,14 @@ export default (win: BrowserWindow) => {
}); });
}, },
// Queue // Queue
addSongToQueue: (videoId: string, queueInsertPosition: string) => { addSongToQueue: (
const videoIdValue = parseStringFromArgsType(videoId); videoIds: string | string[],
if (videoIdValue === null) return; options: {
queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO';
win.webContents.send( index?: number;
'ytmd:add-to-queue', },
videoIdValue, ) => {
queueInsertPosition, win.webContents.send('ytmd:add-to-queue', videoIds, options);
);
}, },
moveSongInQueue: ( moveSongInQueue: (
fromIndex: ArgsType<number>, fromIndex: ArgsType<number>,

View File

@ -171,21 +171,27 @@ async function onApiLoaded() {
} satisfies QueueResponse); } satisfies QueueResponse);
}); });
type AddToQueueOptions = {
queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO';
index?: number;
};
window.ipcRenderer.on( window.ipcRenderer.on(
'ytmd:add-to-queue', '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 queue = document.querySelector<QueueElement>('#queue');
const app = document.querySelector<YouTubeMusicAppElement>('ytmusic-app'); const app = document.querySelector<YouTubeMusicAppElement>('ytmusic-app');
if (!app) return; if (!app) return;
const store = queue?.queue.store.store; const store = queue?.queue.store.store;
console.log('add-to-queue!', ids, queue, app, store);
if (!store) return; if (!store) return;
app.networkManager app.networkManager
.fetch('/music/get_queue', { .fetch('/music/get_queue', {
queueContextParams: store.getState().queue.queueContextParams, queueContextParams: store.getState().queue.queueContextParams,
queueInsertPosition, queueInsertPosition,
videoIds: [videoId], videoIds: ids,
}) })
.then((result) => { .then((result) => {
if ( if (
@ -201,7 +207,8 @@ async function onApiLoaded() {
payload: { payload: {
nextQueueItemId: store.getState().queue.nextQueueItemId, nextQueueItemId: store.getState().queue.nextQueueItemId,
index: index:
queueInsertPosition === 'INSERT_AFTER_CURRENT_VIDEO' (index ??
queueInsertPosition === 'INSERT_AFTER_CURRENT_VIDEO')
? queueItems.findIndex( ? queueItems.findIndex(
(it) => (it) =>
( (
@ -383,8 +390,14 @@ const defineYTMDTransElements = () => {
YTMDTrans.prototype.connectedCallback = function () { YTMDTrans.prototype.connectedCallback = function () {
const that = this as HTMLElement; const that = this as HTMLElement;
const key = that.getAttribute('key'); 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) { if (key) {
const targetHtml = i18t(key); const targetHtml = i18t(key, options);
(that.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy (that.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy
? defaultTrustedTypePolicy.createHTML(targetHtml) ? defaultTrustedTypePolicy.createHTML(targetHtml)
: targetHtml; : targetHtml;

View File

@ -42,6 +42,17 @@ declare module 'solid-js' {
YpYtPaperSpinnerLiteProps; YpYtPaperSpinnerLiteProps;
'tp-yt-paper-icon-button': ComponentProps<'div'> & 'tp-yt-paper-icon-button': ComponentProps<'div'> &
TpYtPaperIconButtonProps; TpYtPaperIconButtonProps;
'tp-yt-paper-listbox': ComponentProps<'div'>;
// Non-ytmusic elements
'ytmd-trans': ComponentProps<'span'> & {
key: string;
} & {
[key: `attr:${strig}`]: unknown;
};
// fallback
'marquee': ComponentProps<'marquee'>;
} }
} }
} }