From 507a70015ed3d5f2d5e0e4aaea635c301af1ad07 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Mon, 17 Feb 2025 20:25:54 +0900 Subject: [PATCH] refactor(music-together): migrate `music-together` plugin (vanilla to solid-js) --- pnpm-lock.yaml | 22 +- src/i18n/resources/ko.json | 2 +- .../api-server/backend/routes/control.ts | 2 +- src/plugins/music-together/api/guest.ts | 117 +++ src/plugins/music-together/api/host.ts | 140 +++ src/plugins/music-together/api/queue.ts | 0 src/plugins/music-together/backend.ts | 53 ++ src/plugins/music-together/connection.ts | 52 +- src/plugins/music-together/constants.ts | 13 + .../context/RendererContext.tsx | 31 + .../music-together/context/ToastContext.tsx | 22 + src/plugins/music-together/element.ts | 148 ---- src/plugins/music-together/icons/connect.svg | 3 - src/plugins/music-together/icons/key.svg | 4 - .../music-together/icons/music-cast.svg | 3 - src/plugins/music-together/icons/off.svg | 4 - src/plugins/music-together/icons/tune.svg | 3 - src/plugins/music-together/index.ts | 834 +----------------- src/plugins/music-together/queue/client.ts | 48 - src/plugins/music-together/queue/index.ts | 1 - src/plugins/music-together/queue/queue.ts | 544 ------------ src/plugins/music-together/queue/sha1hash.ts | 7 - src/plugins/music-together/queue/song.ts | 50 -- src/plugins/music-together/queue/utils.ts | 26 - src/plugins/music-together/src/Button.tsx | 93 ++ src/plugins/music-together/src/Panel.tsx | 154 ++++ src/plugins/music-together/src/PanelItem.tsx | 43 + src/plugins/music-together/src/Status.tsx | 211 +++++ .../music-together/src/icons/IconConnect.tsx | 7 + .../music-together/src/icons/IconKey.tsx | 7 + .../src/icons/IconMusicCast.tsx | 7 + .../music-together/src/icons/IconOff.tsx | 7 + .../music-together/src/icons/IconTune.tsx | 7 + src/plugins/music-together/src/icons/index.ts | 5 + src/plugins/music-together/src/icons/types.ts | 5 + src/plugins/music-together/src/index.tsx | 56 ++ .../music-together/store/connection.ts | 7 + src/plugins/music-together/store/queue.ts | 12 + src/plugins/music-together/store/status.ts | 28 + src/plugins/music-together/store/user.ts | 14 + src/plugins/music-together/style.css | 33 +- .../music-together/templates/item.html | 8 - .../music-together/templates/popup.html | 5 - .../music-together/templates/setting.html | 7 - .../music-together/templates/status.html | 23 - src/plugins/music-together/types.ts | 36 +- src/plugins/music-together/ui/guest.ts | 42 - src/plugins/music-together/ui/host.ts | 62 -- src/plugins/music-together/ui/setting.ts | 49 - src/plugins/music-together/ui/status.ts | 102 --- src/providers/song-controls.ts | 27 +- src/renderer.ts | 21 +- src/yt-web-components.d.ts | 11 + 53 files changed, 1137 insertions(+), 2081 deletions(-) create mode 100644 src/plugins/music-together/api/guest.ts create mode 100644 src/plugins/music-together/api/host.ts create mode 100644 src/plugins/music-together/api/queue.ts create mode 100644 src/plugins/music-together/backend.ts create mode 100644 src/plugins/music-together/constants.ts create mode 100644 src/plugins/music-together/context/RendererContext.tsx create mode 100644 src/plugins/music-together/context/ToastContext.tsx delete mode 100644 src/plugins/music-together/element.ts delete mode 100644 src/plugins/music-together/icons/connect.svg delete mode 100644 src/plugins/music-together/icons/key.svg delete mode 100644 src/plugins/music-together/icons/music-cast.svg delete mode 100644 src/plugins/music-together/icons/off.svg delete mode 100644 src/plugins/music-together/icons/tune.svg delete mode 100644 src/plugins/music-together/queue/client.ts delete mode 100644 src/plugins/music-together/queue/index.ts delete mode 100644 src/plugins/music-together/queue/queue.ts delete mode 100644 src/plugins/music-together/queue/sha1hash.ts delete mode 100644 src/plugins/music-together/queue/song.ts delete mode 100644 src/plugins/music-together/queue/utils.ts create mode 100644 src/plugins/music-together/src/Button.tsx create mode 100644 src/plugins/music-together/src/Panel.tsx create mode 100644 src/plugins/music-together/src/PanelItem.tsx create mode 100644 src/plugins/music-together/src/Status.tsx create mode 100644 src/plugins/music-together/src/icons/IconConnect.tsx create mode 100644 src/plugins/music-together/src/icons/IconKey.tsx create mode 100644 src/plugins/music-together/src/icons/IconMusicCast.tsx create mode 100644 src/plugins/music-together/src/icons/IconOff.tsx create mode 100644 src/plugins/music-together/src/icons/IconTune.tsx create mode 100644 src/plugins/music-together/src/icons/index.ts create mode 100644 src/plugins/music-together/src/icons/types.ts create mode 100644 src/plugins/music-together/src/index.tsx create mode 100644 src/plugins/music-together/store/connection.ts create mode 100644 src/plugins/music-together/store/queue.ts create mode 100644 src/plugins/music-together/store/status.ts create mode 100644 src/plugins/music-together/store/user.ts delete mode 100644 src/plugins/music-together/templates/item.html delete mode 100644 src/plugins/music-together/templates/popup.html delete mode 100644 src/plugins/music-together/templates/setting.html delete mode 100644 src/plugins/music-together/templates/status.html delete mode 100644 src/plugins/music-together/ui/guest.ts delete mode 100644 src/plugins/music-together/ui/host.ts delete mode 100644 src/plugins/music-together/ui/setting.ts delete mode 100644 src/plugins/music-together/ui/status.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3aa1596..4d8d9f3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,13 +14,13 @@ overrides: patchedDependencies: '@malept/flatpak-bundler': - hash: c787371eeb2af011ea934e8818a0dad6d7dcb2df31bbb1686babc7231af0183c + hash: vli4xtu6rndz3xtsscixhngsxa path: patches/@malept__flatpak-bundler.patch app-builder-lib@26.0.6: - hash: 1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f + hash: zcnm2qnjaggm2keyecnhiglkke path: patches/app-builder-lib@26.0.6.patch vudio@2.1.1: - hash: 0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d + hash: 7iux5msqpgl3octdmwy4uspwoe path: patches/vudio@2.1.1.patch importers: @@ -191,7 +191,7 @@ importers: version: 25.0.1 vudio: specifier: 2.1.1 - version: 2.1.1(patch_hash=0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d) + version: 2.1.1(patch_hash=7iux5msqpgl3octdmwy4uspwoe) x11: specifier: 2.3.0 version: 2.3.0 @@ -5365,7 +5365,7 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@malept/flatpak-bundler@0.4.0(patch_hash=c787371eeb2af011ea934e8818a0dad6d7dcb2df31bbb1686babc7231af0183c)': + '@malept/flatpak-bundler@0.4.0(patch_hash=vli4xtu6rndz3xtsscixhngsxa)': dependencies: debug: 4.4.0 fs-extra: 9.1.0 @@ -5832,7 +5832,7 @@ snapshots: app-builder-bin@5.0.0-alpha.12: {} - app-builder-lib@26.0.6(patch_hash=1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6): + app-builder-lib@26.0.6(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6): dependencies: '@develar/schema-utils': 2.6.5 '@electron/asar': 3.2.18 @@ -5841,7 +5841,7 @@ snapshots: '@electron/osx-sign': 1.3.1 '@electron/rebuild': 3.7.0 '@electron/universal': 2.0.1 - '@malept/flatpak-bundler': 0.4.0(patch_hash=c787371eeb2af011ea934e8818a0dad6d7dcb2df31bbb1686babc7231af0183c) + '@malept/flatpak-bundler': 0.4.0(patch_hash=vli4xtu6rndz3xtsscixhngsxa) '@types/fs-extra': 9.0.13 async-exit-hook: 2.0.1 builder-util: 26.0.4 @@ -6464,7 +6464,7 @@ snapshots: dmg-builder@26.0.6(electron-builder-squirrel-windows@26.0.6): dependencies: - app-builder-lib: 26.0.6(patch_hash=1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6) + app-builder-lib: 26.0.6(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6) builder-util: 26.0.4 builder-util-runtime: 9.3.1 fs-extra: 10.1.0 @@ -6541,7 +6541,7 @@ snapshots: electron-builder-squirrel-windows@26.0.6(dmg-builder@26.0.6): dependencies: - app-builder-lib: 26.0.6(patch_hash=1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6) + app-builder-lib: 26.0.6(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6) builder-util: 26.0.4 electron-winstaller: 5.4.0 transitivePeerDependencies: @@ -6551,7 +6551,7 @@ snapshots: electron-builder@26.0.6(electron-builder-squirrel-windows@26.0.6): dependencies: - app-builder-lib: 26.0.6(patch_hash=1acd50242999bb544e3d63fe07eff7541c1500c6db5ff81d2f8e1074b1df044f)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6) + app-builder-lib: 26.0.6(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@26.0.6)(electron-builder-squirrel-windows@26.0.6) builder-util: 26.0.4 builder-util-runtime: 9.3.1 chalk: 4.1.2 @@ -9132,7 +9132,7 @@ snapshots: optionalDependencies: vite: 6.1.0(@types/node@22.13.4)(yaml@2.7.0) - vudio@2.1.1(patch_hash=0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d): {} + vudio@2.1.1(patch_hash=7iux5msqpgl3octdmwy4uspwoe): {} wcwidth@1.0.1: dependencies: diff --git a/src/i18n/resources/ko.json b/src/i18n/resources/ko.json index b525b944..908e91cf 100644 --- a/src/i18n/resources/ko.json +++ b/src/i18n/resources/ko.json @@ -541,7 +541,7 @@ "menu": { "click-to-copy-id": "호스트 아이디 복사", "close": "Music Together 닫기", - "connected-users": "연결된 사용자", + "connected-users": "연결된 사용자: {{count}}명", "disconnect": "Music Together 연결 끊기", "empty-user": "연결된 사용자 없음", "host": "Music Together 호스트", diff --git a/src/plugins/api-server/backend/routes/control.ts b/src/plugins/api-server/backend/routes/control.ts index fccf12dc..00d69c0b 100644 --- a/src/plugins/api-server/backend/routes/control.ts +++ b/src/plugins/api-server/backend/routes/control.ts @@ -720,7 +720,7 @@ export const register = ( app.openapi(routes.addSongToQueue, (ctx) => { const { videoId, insertPosition } = ctx.req.valid('json'); - controller.addSongToQueue(videoId, insertPosition); + controller.addSongToQueue(videoId, { queueInsertPosition: insertPosition }); ctx.status(204); return ctx.body(null); diff --git a/src/plugins/music-together/api/guest.ts b/src/plugins/music-together/api/guest.ts new file mode 100644 index 00000000..f4eda05f --- /dev/null +++ b/src/plugins/music-together/api/guest.ts @@ -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['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; + }, +}; diff --git a/src/plugins/music-together/api/host.ts b/src/plugins/music-together/api/host.ts new file mode 100644 index 00000000..b5d13c13 --- /dev/null +++ b/src/plugins/music-together/api/host.ts @@ -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['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; + }, +}; diff --git a/src/plugins/music-together/api/queue.ts b/src/plugins/music-together/api/queue.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/plugins/music-together/backend.ts b/src/plugins/music-together/backend.ts new file mode 100644 index 00000000..8d7adcca --- /dev/null +++ b/src/plugins/music-together/backend.ts @@ -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) => { + 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), + ); +}; diff --git a/src/plugins/music-together/connection.ts b/src/plugins/music-together/connection.ts index 3c71dcb8..3c06c9be 100644 --- a/src/plugins/music-together/connection.ts +++ b/src/plugins/music-together/connection.ts @@ -1,26 +1,6 @@ import { DataConnection, Peer } from 'peerjs'; -import type { Permission, Profile, VideoData } from './types'; - -export type ConnectionEventMap = { - ADD_SONGS: { videoList: VideoData[]; index?: number }; - REMOVE_SONG: { index: number }; - MOVE_SONG: { fromIndex: number; toIndex: number }; - IDENTIFY: { profile: Profile } | undefined; - SYNC_PROFILE: { profiles: Record } | undefined; - SYNC_QUEUE: { videoList: VideoData[] } | undefined; - SYNC_PROGRESS: - | { progress?: number; state?: number; index?: number } - | undefined; - PERMISSION: Permission | undefined; -}; -export type ConnectionEventUnion = { - [Event in keyof ConnectionEventMap]: { - type: Event; - payload: ConnectionEventMap[Event]; - after?: ConnectionEventUnion[]; - }; -}[keyof ConnectionEventMap]; +import { ConnectedState, ConnectionEventMap, ConnectionEventUnion } from './types'; type PromiseUtil = { promise: Promise; @@ -32,10 +12,10 @@ export type ConnectionListener = ( event: ConnectionEventUnion, conn: DataConnection, ) => void; -export type ConnectionMode = 'host' | 'guest' | 'disconnected'; + export class Connection { private peer: Peer; - private _mode: ConnectionMode = 'disconnected'; + private _state: ConnectedState = 'disconnected'; private connections: Record = {}; private waitOpen: PromiseUtil = {} as PromiseUtil; @@ -51,15 +31,15 @@ export class Connection { }); this.peer.on('open', (id) => { - this._mode = 'host'; + this._state = 'connecting'; this.waitOpen.resolve(id); }); this.peer.on('connection', (conn) => { - this._mode = 'host'; + this._state = 'host'; this.registerConnection(conn); }); this.peer.on('error', (err) => { - this._mode = 'disconnected'; + this._state = 'disconnected'; this.waitOpen.reject(err); this.connectionListeners.forEach((listener) => listener()); @@ -73,16 +53,16 @@ export class Connection { } async connect(id: string) { - this._mode = 'guest'; + this._state = 'guest'; const conn = this.peer.connect(id); await this.registerConnection(conn); return conn; } disconnect() { - if (this._mode === 'disconnected') throw new Error('Already disconnected'); + if (this._state === 'disconnected') throw new Error('Already disconnected'); - this._mode = 'disconnected'; + this._state = 'disconnected'; this.connections = {}; this.peer.destroy(); } @@ -92,8 +72,8 @@ export class Connection { return this.peer.id; } - public get mode() { - return this._mode; + public get state() { + return this._state; } public getConnections() { @@ -121,7 +101,7 @@ export class Connection { private async registerConnection(conn: DataConnection) { return new Promise((resolve, reject) => { this.peer.once('error', (err) => { - this._mode = 'disconnected'; + this._state = 'disconnected'; reject(err); this.connectionListeners.forEach((listener) => listener()); @@ -133,6 +113,12 @@ export class Connection { this.connectionListeners.forEach((listener) => listener(conn)); conn.on('data', (data) => { + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } catch {} + } + if ( !data || typeof data !== 'object' || @@ -140,7 +126,7 @@ export class Connection { !('payload' in data) || !data.type ) { - console.warn('Music Together: Invalid data', data); + console.warn('Music Together: Invalid data', data, typeof data); return; } diff --git a/src/plugins/music-together/constants.ts b/src/plugins/music-together/constants.ts new file mode 100644 index 00000000..4add9801 --- /dev/null +++ b/src/plugins/music-together/constants.ts @@ -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', +}; diff --git a/src/plugins/music-together/context/RendererContext.tsx b/src/plugins/music-together/context/RendererContext.tsx new file mode 100644 index 00000000..82c38c51 --- /dev/null +++ b/src/plugins/music-together/context/RendererContext.tsx @@ -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; +}; +export const RendererContextContext = + createContext(); + +export type RendererContextProviderProps = RendererContextContextType & { + children: JSX.Element; +}; +export const RendererContextProvider = ( + props: RendererContextProviderProps, +) => { + const [local, left] = splitProps(props, ['children']); + return ( + + {local.children} + + ); +}; +export const useRendererContext = () => { + const context = useContext(RendererContextContext); + if (!context) throw Error('RendererContextProvider not found'); + + return context.context; +}; diff --git a/src/plugins/music-together/context/ToastContext.tsx b/src/plugins/music-together/context/ToastContext.tsx new file mode 100644 index 00000000..26c0c66c --- /dev/null +++ b/src/plugins/music-together/context/ToastContext.tsx @@ -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(); + +export type ToastProviderProps = ToastContextType & { + children: JSX.Element; +}; +export const ToastProvider = (props: ToastProviderProps) => ( + {props.children} +); +export const useToast = () => { + const context = useContext(ToastContext); + + return (message: string) => { + context?.service.show(message); + }; +}; diff --git a/src/plugins/music-together/element.ts b/src/plugins/music-together/element.ts deleted file mode 100644 index 035a9b54..00000000 --- a/src/plugins/music-together/element.ts +++ /dev/null @@ -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( - '.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( - '
', - ), - }; - 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('div.icon')!; - const textContainer = element.querySelector('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, - }; -}; diff --git a/src/plugins/music-together/icons/connect.svg b/src/plugins/music-together/icons/connect.svg deleted file mode 100644 index 374bebf8..00000000 --- a/src/plugins/music-together/icons/connect.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/plugins/music-together/icons/key.svg b/src/plugins/music-together/icons/key.svg deleted file mode 100644 index cfa71c8b..00000000 --- a/src/plugins/music-together/icons/key.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/src/plugins/music-together/icons/music-cast.svg b/src/plugins/music-together/icons/music-cast.svg deleted file mode 100644 index e0e075ad..00000000 --- a/src/plugins/music-together/icons/music-cast.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/plugins/music-together/icons/off.svg b/src/plugins/music-together/icons/off.svg deleted file mode 100644 index 9505e203..00000000 --- a/src/plugins/music-together/icons/off.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/src/plugins/music-together/icons/tune.svg b/src/plugins/music-together/icons/tune.svg deleted file mode 100644 index fb50c380..00000000 --- a/src/plugins/music-together/icons/tune.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/plugins/music-together/index.ts b/src/plugins/music-together/index.ts index ae3757ab..32a5138d 100644 --- a/src/plugins/music-together/index.ts +++ b/src/plugins/music-together/index.ts @@ -1,84 +1,10 @@ -import prompt from 'custom-electron-prompt'; - -import { DataConnection } from 'peerjs'; - import { t } from '@/i18n'; import { createPlugin } from '@/utils'; -import promptOptions from '@/providers/prompt-options'; -import { - getDefaultProfile, - type Permission, - type Profile, - type VideoData, -} from './types'; -import { Queue } from './queue'; -import { Connection, type ConnectionEventUnion } from './connection'; -import { createHostPopup } from './ui/host'; -import { createGuestPopup } from './ui/guest'; -import { createSettingPopup } from './ui/setting'; +import { onMainLoad } from './backend'; +import { onRendererLoad } from './src'; -import settingHTML from './templates/setting.html?raw'; -import style from './style.css?inline'; - -import type { YoutubePlayer } from '@/types/youtube-player'; -import type { RendererContext } from '@/types/contexts'; -import type { VideoDataChanged } from '@/types/video-data-changed'; -import type { AppElement } from '@/types/queue'; - -type RawAccountData = { - accountName: { - runs: { text: string }[]; - }; - accountPhoto: { - thumbnails: { url: string; width: number; height: number }[]; - }; - settingsEndpoint: unknown; - manageAccountTitle: unknown; - trackingParams: string; - channelHandle: { - runs: { text: string }[]; - }; -}; - -export default createPlugin< - unknown, - unknown, - { - connection?: Connection; - ipc?: RendererContext['ipc']; - api: AppElement | null; - queue?: Queue; - playerApi?: YoutubePlayer; - showPrompt: (title: string, label: string) => Promise; - popups: { - host: ReturnType; - guest: ReturnType; - setting: ReturnType; - }; - elements: { - setting: HTMLElement; - icon: SVGElement; - spinner: HTMLElement; - }; - stateInterval?: number; - updateNext: boolean; - ignoreChange: boolean; - rollbackInjector?: () => void; - me?: Omit; - profiles: Record; - permission: Permission; - videoChangeListener: (event: CustomEvent) => void; - videoStateChangeListener: () => void; - onHost: () => Promise; - onJoin: () => Promise; - onStop: () => void; - putProfile: (id: string, profile?: Profile) => void; - showSpinner: () => void; - hideSpinner: () => void; - initMyProfile: () => void; - } ->({ +export default createPlugin({ name: () => t('plugins.music-together.name'), description: () => t('plugins.music-together.description'), restartNeeded: false, @@ -86,757 +12,9 @@ export default createPlugin< config: { enabled: false, }, - stylesheets: [style], - backend({ ipc }) { - ipc.handle('music-together:prompt', async (title: string, label: string) => - prompt({ - title, - label, - type: 'input', - ...promptOptions(), - }), - ); - }, + stylesheets: [], + backend: onMainLoad, renderer: { - updateNext: false, - ignoreChange: false, - permission: 'playlist', - popups: {} as { - host: ReturnType; - guest: ReturnType; - setting: ReturnType; - }, - elements: {} as { - setting: HTMLElement; - icon: SVGElement; - spinner: HTMLElement; - }, - profiles: {}, - showPrompt: () => Promise.resolve(''), - api: null, - - /* events */ - videoChangeListener(event: CustomEvent) { - 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; - this.api = document.querySelector('ytmusic-app'); - - /* setup */ - document - .querySelector('#right-content > ytmusic-settings-button') - ?.insertAdjacentHTML('beforebegin', settingHTML); - const setting = document.querySelector( - '#music-together-setting-button', - ); - const icon = document.querySelector( - '#music-together-setting-button > svg', - ); - const spinner = document.querySelector( - '#music-together-setting-button > tp-yt-paper-spinner-lite', - ); - if (!setting || !icon || !spinner) { - console.warn('Music Together: Cannot inject html'); - console.log(setting, icon, spinner); - return; - } - - this.elements = { - setting, - icon, - spinner, - }; - - this.stateInterval = window.setInterval(() => { - if (this.connection?.mode !== 'host') return; - const index = this.queue?.selectedIndex ?? 0; - - this.connection.broadcast('SYNC_PROGRESS', { - progress: this.playerApi?.getCurrentTime(), - state: this.playerApi?.getPlayerState(), - index, - }); - }, 1000); - - /* UI */ - const hostPopup = createHostPopup({ - onItemClick: (id) => { - if (id === 'music-together-close') { - this.onStop(); - this.api?.toastService?.show( - t('plugins.music-together.toast.closed'), - ); - hostPopup.dismiss(); - } - - if (id === 'music-together-copy-id') { - navigator.clipboard - .writeText(this.connection?.id ?? '') - .then(() => { - this.api?.toastService?.show( - t('plugins.music-together.toast.id-copied'), - ); - hostPopup.dismiss(); - }) - .catch(() => { - this.api?.toastService?.show( - t('plugins.music-together.toast.id-copy-failed'), - ); - hostPopup.dismiss(); - }); - } - - if (id === 'music-together-permission') { - if (this.permission === 'all') this.permission = 'host-only'; - else if (this.permission === 'playlist') this.permission = 'all'; - else if (this.permission === 'host-only') - this.permission = 'playlist'; - this.connection?.broadcast('PERMISSION', this.permission); - - hostPopup.setPermission(this.permission); - guestPopup.setPermission(this.permission); - settingPopup.setPermission(this.permission); - - const permissionLabel = t( - `plugins.music-together.menu.permission.${this.permission}`, - ); - this.api?.toastService?.show( - t('plugins.music-together.toast.permission-changed', { - permission: permissionLabel, - }), - ); - const item = hostPopup.items.find((it) => it?.element.id === id); - if (item?.type === 'item') { - item.setText(t('plugins.music-together.menu.set-permission')); - } - } - }, - }); - const guestPopup = createGuestPopup({ - onItemClick: (id) => { - if (id === 'music-together-disconnect') { - this.onStop(); - this.api?.toastService?.show( - t('plugins.music-together.toast.disconnected'), - ); - guestPopup.dismiss(); - } - }, - }); - const settingPopup = createSettingPopup({ - onItemClick: async (id) => { - if (id === 'music-together-host') { - settingPopup.dismiss(); - this.showSpinner(); - const result = await this.onHost(); - this.hideSpinner(); - - if (result) { - navigator.clipboard - .writeText(this.connection?.id ?? '') - .then(() => { - this.api?.toastService?.show( - t('plugins.music-together.toast.id-copied'), - ); - hostPopup.showAtAnchor(setting); - }) - .catch(() => { - this.api?.toastService?.show( - t('plugins.music-together.toast.id-copy-failed'), - ); - hostPopup.showAtAnchor(setting); - }); - } else { - this.api?.toastService?.show( - t('plugins.music-together.toast.host-failed'), - ); - } - } - - if (id === 'music-together-join') { - settingPopup.dismiss(); - this.showSpinner(); - const result = await this.onJoin(); - this.hideSpinner(); - - if (result) { - this.api?.toastService?.show( - t('plugins.music-together.toast.joined'), - ); - guestPopup.showAtAnchor(setting); - } else { - this.api?.toastService?.show( - t('plugins.music-together.toast.join-failed'), - ); - } - } - }, - }); - this.popups = { - host: hostPopup, - guest: guestPopup, - setting: settingPopup, - }; - setting.addEventListener('click', () => { - let popup = settingPopup; - if (this.connection?.mode === 'host') popup = hostPopup; - if (this.connection?.mode === 'guest') popup = guestPopup; - - if (popup.isShowing()) popup.dismiss(); - else popup.showAtAnchor(setting); - }); - - /* account data getter */ - this.initMyProfile(); - }, - onPlayerApiReady(playerApi) { - this.queue = new Queue({ - owner: { - id: this.connection?.id ?? '', - ...this.me!, - }, - getProfile: (id) => this.profiles[id], - }); - this.playerApi = playerApi; - - this.playerApi.addEventListener( - 'onStateChange', - this.videoStateChangeListener, - ); - document.addEventListener('videodatachange', this.videoChangeListener); - }, - stop() { - const dividers = Array.from( - document.querySelectorAll('.music-together-divider'), - ); - dividers.forEach((divider) => divider.remove()); - - this.elements.setting?.remove(); - this.onStop(); - if (typeof this.stateInterval === 'number') - clearInterval(this.stateInterval); - if (this.playerApi) - this.playerApi.removeEventListener( - 'onStateChange', - this.videoStateChangeListener, - ); - if (this.videoChangeListener) - document.removeEventListener( - 'videodatachange', - this.videoChangeListener, - ); - }, + start: onRendererLoad, }, }); diff --git a/src/plugins/music-together/queue/client.ts b/src/plugins/music-together/queue/client.ts deleted file mode 100644 index 38aabe86..00000000 --- a/src/plugins/music-together/queue/client.ts +++ /dev/null @@ -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(), - }; -}; diff --git a/src/plugins/music-together/queue/index.ts b/src/plugins/music-together/queue/index.ts deleted file mode 100644 index cadd6a98..00000000 --- a/src/plugins/music-together/queue/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './queue'; diff --git a/src/plugins/music-together/queue/queue.ts b/src/plugins/music-together/queue/queue.ts deleted file mode 100644 index ef91fe51..00000000 --- a/src/plugins/music-together/queue/queue.ts +++ /dev/null @@ -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('#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('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('.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('.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('ytmusic-player-queue-item') ?? [], - ); - - list.forEach((item) => { - const profile = item.querySelector( - '.music-together-owner', - ); - const name = item.querySelector('.music-together-name'); - profile?.remove(); - name?.remove(); - }); - }); - } - - /* private */ - private broadcast(event: ConnectionEventUnion) { - this.listeners.forEach((listener) => listener(event)); - } -} diff --git a/src/plugins/music-together/queue/sha1hash.ts b/src/plugins/music-together/queue/sha1hash.ts deleted file mode 100644 index 48d09d1f..00000000 --- a/src/plugins/music-together/queue/sha1hash.ts +++ /dev/null @@ -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(''); -}; diff --git a/src/plugins/music-together/queue/song.ts b/src/plugins/music-together/queue/song.ts deleted file mode 100644 index e791c942..00000000 --- a/src/plugins/music-together/queue/song.ts +++ /dev/null @@ -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 => { - 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; -}; diff --git a/src/plugins/music-together/queue/utils.ts b/src/plugins/music-together/queue/utils.ts deleted file mode 100644 index f68f7cae..00000000 --- a/src/plugins/music-together/queue/utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - ItemPlaylistPanelVideoRenderer, - PlaylistPanelVideoWrapperRenderer, - QueueItem, -} from '@/types/datahost-get-state'; - -export const mapQueueItem = ( - 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); diff --git a/src/plugins/music-together/src/Button.tsx b/src/plugins/music-together/src/Button.tsx new file mode 100644 index 00000000..72455d26 --- /dev/null +++ b/src/plugins/music-together/src/Button.tsx @@ -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(null); + const [panel, setPanel] = createSignal(null); + + const position = useFloating(anchor, panel, { + whileElementsMounted: autoUpdate, + strategy: 'fixed', + placement: 'bottom-end', + middleware: [ + offset({ + mainAxis: 4, + crossAxis: 0, + }), + flip({ fallbackStrategy: 'bestFit' }), + ], + }); + + return ( +
+ setEnabled(!enabled())} + > + + + + +
+ +
+
+
+
+ ); +}; diff --git a/src/plugins/music-together/src/Panel.tsx b/src/plugins/music-together/src/Panel.tsx new file mode 100644 index 00000000..a826140c --- /dev/null +++ b/src/plugins/music-together/src/Panel.tsx @@ -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('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 ( + + + + +
+ } + onClick={onHost} + /> + } + /> + + +
+ } + onClick={onHostCopy} + /> + } + /> +
+ } + onClick={onClose} + /> + + +
+ } + onClick={onClose} + /> + + + + ); +}; diff --git a/src/plugins/music-together/src/PanelItem.tsx b/src/plugins/music-together/src/PanelItem.tsx new file mode 100644 index 00000000..b5fbf88e --- /dev/null +++ b/src/plugins/music-together/src/PanelItem.tsx @@ -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 ( +
+
+ {props.icon} +
+
+ {props.text} +
+
+ ); +}; diff --git a/src/plugins/music-together/src/Status.tsx b/src/plugins/music-together/src/Status.tsx new file mode 100644 index 00000000..39bf6853 --- /dev/null +++ b/src/plugins/music-together/src/Status.tsx @@ -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 ( +
+
+ Profile Image +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ + } + > +
+
+ +
+
+ + + + } + > + {(user) => ( + {`${user.name} + )} + +
+ +
+ ); +}; diff --git a/src/plugins/music-together/src/icons/IconConnect.tsx b/src/plugins/music-together/src/icons/IconConnect.tsx new file mode 100644 index 00000000..8dd03544 --- /dev/null +++ b/src/plugins/music-together/src/icons/IconConnect.tsx @@ -0,0 +1,7 @@ +import { IconProps } from './types'; + +export const IconConnect = (props: IconProps) => ( + + + +); diff --git a/src/plugins/music-together/src/icons/IconKey.tsx b/src/plugins/music-together/src/icons/IconKey.tsx new file mode 100644 index 00000000..46512f0d --- /dev/null +++ b/src/plugins/music-together/src/icons/IconKey.tsx @@ -0,0 +1,7 @@ +import { IconProps } from './types'; + +export const IconKey = (props: IconProps) => ( + + + +); diff --git a/src/plugins/music-together/src/icons/IconMusicCast.tsx b/src/plugins/music-together/src/icons/IconMusicCast.tsx new file mode 100644 index 00000000..f37a28c7 --- /dev/null +++ b/src/plugins/music-together/src/icons/IconMusicCast.tsx @@ -0,0 +1,7 @@ +import { IconProps } from './types'; + +export const IconMusicCast = (props: IconProps) => ( + + + +); diff --git a/src/plugins/music-together/src/icons/IconOff.tsx b/src/plugins/music-together/src/icons/IconOff.tsx new file mode 100644 index 00000000..fde1272e --- /dev/null +++ b/src/plugins/music-together/src/icons/IconOff.tsx @@ -0,0 +1,7 @@ +import { IconProps } from './types'; + +export const IconOff = (props: IconProps) => ( + + + +); diff --git a/src/plugins/music-together/src/icons/IconTune.tsx b/src/plugins/music-together/src/icons/IconTune.tsx new file mode 100644 index 00000000..70552e47 --- /dev/null +++ b/src/plugins/music-together/src/icons/IconTune.tsx @@ -0,0 +1,7 @@ +import { IconProps } from './types'; + +export const IconTune = (props: IconProps) => ( + + + +); diff --git a/src/plugins/music-together/src/icons/index.ts b/src/plugins/music-together/src/icons/index.ts new file mode 100644 index 00000000..73ee6f39 --- /dev/null +++ b/src/plugins/music-together/src/icons/index.ts @@ -0,0 +1,5 @@ +export * from './IconConnect'; +export * from './IconKey'; +export * from './IconMusicCast'; +export * from './IconOff'; +export * from './IconTune'; diff --git a/src/plugins/music-together/src/icons/types.ts b/src/plugins/music-together/src/icons/types.ts new file mode 100644 index 00000000..5ae97a99 --- /dev/null +++ b/src/plugins/music-together/src/icons/types.ts @@ -0,0 +1,5 @@ +export type IconProps = { + width?: number; + height?: number; + fill?: string; +}; diff --git a/src/plugins/music-together/src/index.tsx b/src/plugins/music-together/src/index.tsx new file mode 100644 index 00000000..2d113648 --- /dev/null +++ b/src/plugins/music-together/src/index.tsx @@ -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, +) => { + const container = document.createElement('div'); + const target = document.querySelector( + '#right-content > ytmusic-settings-button', + ); + const api = document.querySelector('ytmusic-app'); + + if (!target) { + console.warn('Music Together [renderer]: Cannot inject a button'); + return; + } + + const button = target.querySelector('tp-yt-paper-icon-button'); + button?.click(); + + const interval = setInterval(() => { + const thumbnail = target?.querySelector('img')?.src; + const name = document.querySelector('#account-name')?.textContent; + + if (name) { + setUser({ name, thumbnail }); + + clearInterval(interval); + setTimeout(() => { + button?.click(); + + target?.insertAdjacentElement('beforebegin', container); + render( + () => ( + + + + + + ), + container, + ); + }, 0); + } + }, 1); +}; diff --git a/src/plugins/music-together/store/connection.ts b/src/plugins/music-together/store/connection.ts new file mode 100644 index 00000000..aba323bf --- /dev/null +++ b/src/plugins/music-together/store/connection.ts @@ -0,0 +1,7 @@ +import { createSignal } from 'solid-js'; + +import { Connection } from '../connection'; + +export const [connection, setConnection] = createSignal( + null, +); diff --git a/src/plugins/music-together/store/queue.ts b/src/plugins/music-together/store/queue.ts new file mode 100644 index 00000000..1602bcf1 --- /dev/null +++ b/src/plugins/music-together/store/queue.ts @@ -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({ + queue: [], + title: '', +}); diff --git a/src/plugins/music-together/store/status.ts b/src/plugins/music-together/store/status.ts new file mode 100644 index 00000000..e2eec2a6 --- /dev/null +++ b/src/plugins/music-together/store/status.ts @@ -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({ + mode: 'disconnected', + permission: 'all', + users: [], +}); diff --git a/src/plugins/music-together/store/user.ts b/src/plugins/music-together/store/user.ts new file mode 100644 index 00000000..30667818 --- /dev/null +++ b/src/plugins/music-together/store/user.ts @@ -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({ + name, + thumbnail, +}); diff --git a/src/plugins/music-together/style.css b/src/plugins/music-together/style.css index 2336eebc..af9907c2 100644 --- a/src/plugins/music-together/style.css +++ b/src/plugins/music-together/style.css @@ -3,6 +3,7 @@ cursor: pointer; margin-left: 8px; + margin-right: 16px; & svg { width: 24px; @@ -83,42 +84,15 @@ } .music-together-status { - display: flex; - flex-direction: column; - align-items: stretch; - - padding: 16px; } .music-together-profile { - width: 24px; - height: 24px; - border-radius: 50%; - overflow: hidden; - flex-shrink: 0; } .music-together-profile.big { - width: 32px; - height: 32px; } .music-together-status-container { - flex: 1; - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 16px; } .music-together-status-item { - display: inline-flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-size: 14px; - font-weight: 400; } .music-together-user-container { display: flex; @@ -131,11 +105,6 @@ font-size: 14px; } .music-together-empty { - width: 100%; - - font-size: 14px; - color: rgba(255, 255, 255, .5); - text-align: center; } .music-together-owner { diff --git a/src/plugins/music-together/templates/item.html b/src/plugins/music-together/templates/item.html deleted file mode 100644 index 1c5a43d7..00000000 --- a/src/plugins/music-together/templates/item.html +++ /dev/null @@ -1,8 +0,0 @@ -
-
- -
-
- -
-
diff --git a/src/plugins/music-together/templates/popup.html b/src/plugins/music-together/templates/popup.html deleted file mode 100644 index e2843846..00000000 --- a/src/plugins/music-together/templates/popup.html +++ /dev/null @@ -1,5 +0,0 @@ -
- - - -
diff --git a/src/plugins/music-together/templates/setting.html b/src/plugins/music-together/templates/setting.html deleted file mode 100644 index b6d6897b..00000000 --- a/src/plugins/music-together/templates/setting.html +++ /dev/null @@ -1,7 +0,0 @@ -
- - - - -
-
diff --git a/src/plugins/music-together/templates/status.html b/src/plugins/music-together/templates/status.html deleted file mode 100644 index c273930b..00000000 --- a/src/plugins/music-together/templates/status.html +++ /dev/null @@ -1,23 +0,0 @@ -
-
- Profile Image -
- - - - - - - -
-
-
-
- -
-
- - - -
-
diff --git a/src/plugins/music-together/types.ts b/src/plugins/music-together/types.ts index 08c5812a..2b886112 100644 --- a/src/plugins/music-together/types.ts +++ b/src/plugins/music-together/types.ts @@ -1,4 +1,7 @@ -export type Profile = { +export type MusicTogetherConfig = { + enabled: boolean; +}; +export type User = { id: string; handleId: string; name: string; @@ -8,18 +11,25 @@ export type VideoData = { videoId: string; ownerId: string; }; +export type ConnectedState = 'disconnected' | 'host' | 'guest' | 'connecting'; export type Permission = 'host-only' | 'playlist' | 'all'; -export const getDefaultProfile = ( - connectionID: string, - id: string = Date.now().toString(), -): Profile => { - const name = `Guest ${id.slice(0, 4)}`; - - return { - id: connectionID, - handleId: `#music-together:${id}`, - name, - thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`, - }; +export type ConnectionEventMap = { + ADD_SONGS: { videoList: VideoData[]; index?: number }; + REMOVE_SONG: { index: number }; + MOVE_SONG: { fromIndex: number; toIndex: number }; + IDENTIFY: { user: User } | undefined; + SYNC_USER: { users: User[] } | undefined; + SYNC_QUEUE: { videoList: VideoData[] } | undefined; + SYNC_PROGRESS: + | { progress?: number; state?: number; index?: number } + | undefined; + PERMISSION: Permission | undefined; }; +export type ConnectionEventUnion = { + [Event in keyof ConnectionEventMap]: { + type: Event; + payload: ConnectionEventMap[Event]; + after?: ConnectionEventUnion[]; + }; +}[keyof ConnectionEventMap]; diff --git a/src/plugins/music-together/ui/guest.ts b/src/plugins/music-together/ui/guest.ts deleted file mode 100644 index 28944dcb..00000000 --- a/src/plugins/music-together/ui/guest.ts +++ /dev/null @@ -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, - }; -}; diff --git a/src/plugins/music-together/ui/host.ts b/src/plugins/music-together/ui/host.ts deleted file mode 100644 index aae8a40d..00000000 --- a/src/plugins/music-together/ui/host.ts +++ /dev/null @@ -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, - }; -}; diff --git a/src/plugins/music-together/ui/setting.ts b/src/plugins/music-together/ui/setting.ts deleted file mode 100644 index 6ce72e8e..00000000 --- a/src/plugins/music-together/ui/setting.ts +++ /dev/null @@ -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, - }; -}; diff --git a/src/plugins/music-together/ui/status.ts b/src/plugins/music-together/ui/status.ts deleted file mode 100644 index d303169b..00000000 --- a/src/plugins/music-together/ui/status.ts +++ /dev/null @@ -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( - 'ytmusic-settings-button > tp-yt-paper-icon-button > tp-yt-iron-icon#icon img', - ); - - const profile = element.querySelector( - '.music-together-profile', - )!; - const statusLabel = element.querySelector( - '#music-together-status-label', - )!; - const permissionLabel = element.querySelector( - '#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( - '.music-together-user-container', - )!; - const empty = element.querySelector('.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, - }; -}; diff --git a/src/providers/song-controls.ts b/src/providers/song-controls.ts index e03aa44a..5fee3e66 100644 --- a/src/providers/song-controls.ts +++ b/src/providers/song-controls.ts @@ -24,16 +24,6 @@ const parseBooleanFromArgsType = (args: ArgsType) => { } }; -const parseStringFromArgsType = (args: ArgsType) => { - if (typeof args === 'string') { - return args; - } else if (Array.isArray(args)) { - return args[0]; - } else { - return null; - } -}; - export default (win: BrowserWindow) => { return { // Playback @@ -100,15 +90,14 @@ export default (win: BrowserWindow) => { }); }, // Queue - addSongToQueue: (videoId: string, queueInsertPosition: string) => { - const videoIdValue = parseStringFromArgsType(videoId); - if (videoIdValue === null) return; - - win.webContents.send( - 'ytmd:add-to-queue', - videoIdValue, - queueInsertPosition, - ); + addSongToQueue: ( + videoIds: string | string[], + options: { + queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO'; + index?: number; + }, + ) => { + win.webContents.send('ytmd:add-to-queue', videoIds, options); }, moveSongInQueue: ( fromIndex: ArgsType, diff --git a/src/renderer.ts b/src/renderer.ts index e7be8bcf..8a95aecb 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -171,21 +171,27 @@ async function onApiLoaded() { } satisfies QueueResponse); }); + type AddToQueueOptions = { + queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO'; + index?: number; + }; window.ipcRenderer.on( 'ytmd:add-to-queue', - (_, videoId: string, queueInsertPosition: string) => { + (_, videoIds: string | string[], { queueInsertPosition = 'INSERT_AT_END', index }: AddToQueueOptions) => { + const ids = Array.isArray(videoIds) ? videoIds : [videoIds]; const queue = document.querySelector('#queue'); const app = document.querySelector('ytmusic-app'); if (!app) return; const store = queue?.queue.store.store; + console.log('add-to-queue!', ids, queue, app, store); if (!store) return; app.networkManager .fetch('/music/get_queue', { queueContextParams: store.getState().queue.queueContextParams, queueInsertPosition, - videoIds: [videoId], + videoIds: ids, }) .then((result) => { if ( @@ -201,7 +207,8 @@ async function onApiLoaded() { payload: { nextQueueItemId: store.getState().queue.nextQueueItemId, index: - queueInsertPosition === 'INSERT_AFTER_CURRENT_VIDEO' + (index ?? + queueInsertPosition === 'INSERT_AFTER_CURRENT_VIDEO') ? queueItems.findIndex( (it) => ( @@ -383,8 +390,14 @@ const defineYTMDTransElements = () => { YTMDTrans.prototype.connectedCallback = function () { const that = this as HTMLElement; const key = that.getAttribute('key'); + const options: Record = {}; + that.getAttributeNames().forEach((attr) => { + if (attr === 'key') return; + + options[attr] = that.getAttribute(attr); + }); if (key) { - const targetHtml = i18t(key); + const targetHtml = i18t(key, options); (that.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy ? defaultTrustedTypePolicy.createHTML(targetHtml) : targetHtml; diff --git a/src/yt-web-components.d.ts b/src/yt-web-components.d.ts index 336ac1f0..a88562e8 100644 --- a/src/yt-web-components.d.ts +++ b/src/yt-web-components.d.ts @@ -42,6 +42,17 @@ declare module 'solid-js' { YpYtPaperSpinnerLiteProps; 'tp-yt-paper-icon-button': ComponentProps<'div'> & TpYtPaperIconButtonProps; + 'tp-yt-paper-listbox': ComponentProps<'div'>; + + // Non-ytmusic elements + 'ytmd-trans': ComponentProps<'span'> & { + key: string; + } & { + [key: `attr:${strig}`]: unknown; + }; + + // fallback + 'marquee': ComponentProps<'marquee'>; } } }