From ee0c512529a9aa3294590dbaeb89c02dabda4825 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 31 Dec 2023 13:52:15 +0900 Subject: [PATCH] feat(music-together): Add new plugin `Music Together` (#1562) * feat(music-together): test `peerjs` * feat(music-together): replace `prompt` to `custom-electron-prompt` * fix(music-together): fix * test fix * wow * test * feat(music-together): improve `onStart` * fix: adblocker * fix(adblock): fix crash with `peerjs` * feat(music-together): add host UI * feat(music-together): implement addSong, removeSong, syncQueue * feat(music-together): inject panel * feat(music-together): redesign music together panel * feat(music-together): sync queue, profile * feat(music-together): sync progress, song, state * fix(music-together): fix some bug * fix(music-together): fix sync queue * feat(music-together): support i18n * feat(music-together): improve sync queue * feat(music-together): add profile in music item * refactor(music-together): refactor structure * feat(music-together): add permission * fix(music-together): fix queue sync bug * fix(music-together): fix some bugs * fix(music-together): fix permission not working on guest mode * fix(music-together): fix queue sync relate bugs * fix(music-together): fix automix items not append using music together * fix(music-together): fix * feat(music-together): improve video injection * fix(music-together): fix injection code * fix(music-together): fix broadcast guest * feat(music-together): add more permission * fix(music-together): fix injector * fix(music-together): fix guest add song logic * feat(music-together): add popup close listener * fix(music-together): fix connection issue * fix(music-together): fix connection issue 2 * feat(music-together): reserve playlist * fix(music-together): exclude automix songs * fix(music-together): fix playlist index sync bug * fix(music-together): fix connection failed error and sync index * fix(music-together): fix host set index bug * fix: apply fix from eslint * feat(util): add `ImageElementFromSrc` * chore(util): update jsdoc * feat(music-together): add owner name * chore(music-together): add translation * feat(music-together): add progress sync * chore(music-together): remove `console.log` --------- Co-authored-by: JellyBrick --- package.json | 1 + pnpm-lock.yaml | 116 +++ src/i18n/resources/en.json | 45 ++ src/i18n/resources/ko.json | 45 ++ src/plugins/adblocker/index.ts | 2 +- src/plugins/adblocker/injectors/inject.js | 34 +- src/plugins/music-together/connection.ts | 149 ++++ src/plugins/music-together/element.ts | 138 ++++ 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 | 680 ++++++++++++++++++ src/plugins/music-together/queue/client.ts | 40 ++ src/plugins/music-together/queue/index.ts | 1 + src/plugins/music-together/queue/queue.ts | 429 +++++++++++ src/plugins/music-together/queue/sha1hash.ts | 117 +++ src/plugins/music-together/queue/song.ts | 48 ++ src/plugins/music-together/queue/utils.ts | 15 + src/plugins/music-together/style.css | 160 +++++ .../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 | 54 ++ src/plugins/music-together/ui/guest.ts | 43 ++ src/plugins/music-together/ui/host.ts | 60 ++ src/plugins/music-together/ui/setting.ts | 49 ++ src/plugins/music-together/ui/status.ts | 82 +++ src/plugins/utils/renderer/html.ts | 17 +- src/types/player-api-events.ts | 1 + src/types/youtube-player.ts | 22 +- src/youtube-music.d.ts | 5 + 34 files changed, 2383 insertions(+), 30 deletions(-) create mode 100644 src/plugins/music-together/connection.ts create mode 100644 src/plugins/music-together/element.ts create mode 100644 src/plugins/music-together/icons/connect.svg create mode 100644 src/plugins/music-together/icons/key.svg create mode 100644 src/plugins/music-together/icons/music-cast.svg create mode 100644 src/plugins/music-together/icons/off.svg create mode 100644 src/plugins/music-together/icons/tune.svg create mode 100644 src/plugins/music-together/index.ts create mode 100644 src/plugins/music-together/queue/client.ts create mode 100644 src/plugins/music-together/queue/index.ts create mode 100644 src/plugins/music-together/queue/queue.ts create mode 100644 src/plugins/music-together/queue/sha1hash.ts create mode 100644 src/plugins/music-together/queue/song.ts create mode 100644 src/plugins/music-together/queue/utils.ts create mode 100644 src/plugins/music-together/style.css create mode 100644 src/plugins/music-together/templates/item.html create mode 100644 src/plugins/music-together/templates/popup.html create mode 100644 src/plugins/music-together/templates/setting.html create mode 100644 src/plugins/music-together/templates/status.html create mode 100644 src/plugins/music-together/types.ts create mode 100644 src/plugins/music-together/ui/guest.ts create mode 100644 src/plugins/music-together/ui/host.ts create mode 100644 src/plugins/music-together/ui/setting.ts create mode 100644 src/plugins/music-together/ui/status.ts diff --git a/package.json b/package.json index 806880f8..56289bfc 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "keyboardevents-areequal": "0.2.2", "node-html-parser": "6.1.12", "node-id3": "0.2.6", + "peerjs": "1.5.2", "serve": "14.2.1", "simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9", "ts-morph": "21.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6032cd69..ec9aba4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ dependencies: node-id3: specifier: 0.2.6 version: 0.2.6 + peerjs: + specifier: 1.5.2 + version: 1.5.2 serve: specifier: 14.2.1 version: 14.2.1 @@ -465,6 +468,54 @@ packages: to-fast-properties: 2.0.0 dev: true + /@cbor-extract/cbor-extract-darwin-arm64@2.1.1: + resolution: {integrity: sha512-blVBy5MXz6m36Vx0DfLd7PChOQKEs8lK2bD1WJn/vVgG4FXZiZmZb2GECHFvVPA5T7OnODd9xZiL3nMCv6QUhA==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@cbor-extract/cbor-extract-darwin-x64@2.1.1: + resolution: {integrity: sha512-h6KFOzqk8jXTvkOftyRIWGrd7sKQzQv2jVdTL9nKSf3D2drCvQB/LHUxAOpPXo3pv2clDtKs3xnHalpEh3rDsw==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@cbor-extract/cbor-extract-linux-arm64@2.1.1: + resolution: {integrity: sha512-SxAaRcYf8S0QHaMc7gvRSiTSr7nUYMqbUdErBEu+HYA4Q6UNydx1VwFE68hGcp1qvxcy9yT5U7gA+a5XikfwSQ==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@cbor-extract/cbor-extract-linux-arm@2.1.1: + resolution: {integrity: sha512-ds0uikdcIGUjPyraV4oJqyVE5gl/qYBpa/Wnh6l6xLE2lj/hwnjT2XcZCChdXwW/YFZ1LUHs6waoYN8PmK0nKQ==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@cbor-extract/cbor-extract-linux-x64@2.1.1: + resolution: {integrity: sha512-GVK+8fNIE9lJQHAlhOROYiI0Yd4bAZ4u++C2ZjlkS3YmO6hi+FUxe6Dqm+OKWTcMpL/l71N6CQAmaRcb4zyJuA==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@cbor-extract/cbor-extract-win32-x64@2.1.1: + resolution: {integrity: sha512-2Niq1C41dCRIDeD8LddiH+mxGlO7HJ612Ll3D/E73ZWBmycued+8ghTr/Ho3CMOWPUEr08XtyBMVXAjqF+TcKw==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@cliqz/adblocker-content@1.26.12: resolution: {integrity: sha512-4LWW3kntWuTDo10u24uuk0GmTzegkw9cZ8eDBzzDvHOtRVRMUv4fuoaWCwnB6UpA1VH7iU5nCbRlXNvjnnUA2Q==} dependencies: @@ -953,6 +1004,11 @@ packages: - supports-color dev: true + /@msgpack/msgpack@2.8.0: + resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} + engines: {node: '>= 10'} + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2108,6 +2164,28 @@ packages: resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==} dev: true + /cbor-extract@2.1.1: + resolution: {integrity: sha512-1UX977+L+zOJHsp0mWFG13GLwO6ucKgSmSW6JTl8B9GUvACvHeIVpFqhU92299Z6PfD09aTXDell5p+lp1rUFA==} + hasBin: true + requiresBuild: true + dependencies: + node-gyp-build-optional-packages: 5.0.3 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.1.1 + '@cbor-extract/cbor-extract-darwin-x64': 2.1.1 + '@cbor-extract/cbor-extract-linux-arm': 2.1.1 + '@cbor-extract/cbor-extract-linux-arm64': 2.1.1 + '@cbor-extract/cbor-extract-linux-x64': 2.1.1 + '@cbor-extract/cbor-extract-win32-x64': 2.1.1 + dev: false + optional: true + + /cbor-x@1.5.4: + resolution: {integrity: sha512-PVKILDn+Rf6MRhhcyzGXi5eizn1i0i3F8Fe6UMMxXBnWkalq9+C5+VTmlIjAYM4iF2IYF2N+zToqAfYOp+3rfw==} + optionalDependencies: + cbor-extract: 2.1.1 + dev: false + /chalk-template@0.4.0: resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} engines: {node: '>=12'} @@ -3267,6 +3345,10 @@ packages: through: 2.3.8 dev: false + /eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: false + /execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4704,6 +4786,13 @@ packages: formdata-polyfill: 4.0.10 dev: false + /node-gyp-build-optional-packages@5.0.3: + resolution: {integrity: sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==} + hasBin: true + requiresBuild: true + dev: false + optional: true + /node-gyp-build@4.7.1: resolution: {integrity: sha512-wTSrZ+8lsRRa3I3H8Xr65dLWSgCvY2l4AOnaeKdPA9TB/WYMPaTcrzf3rXvFoVvjKNVnu0CcWSx54qq9GKRUYg==} hasBin: true @@ -5024,6 +5113,22 @@ packages: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} dev: false + /peerjs-js-binarypack@2.1.0: + resolution: {integrity: sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg==} + engines: {node: '>= 14.0.0'} + dev: false + + /peerjs@1.5.2: + resolution: {integrity: sha512-pPrtNwPyWJHRPxy2y+rHcdlrG8UwUBB1nl+3Yj6r7FLwcbBpcB2NvGNvLvcrxAVGGGX9fsdA5VT5zBKTZcm1DQ==} + engines: {node: '>= 14'} + dependencies: + '@msgpack/msgpack': 2.8.0 + cbor-x: 1.5.4 + eventemitter3: 4.0.7 + peerjs-js-binarypack: 2.1.0 + webrtc-adapter: 8.2.3 + dev: false + /pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -5373,6 +5478,10 @@ packages: /sax@1.3.0: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} + /sdp@3.2.0: + resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==} + dev: false + /selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} dependencies: @@ -6148,6 +6257,13 @@ packages: engines: {node: '>= 8'} dev: false + /webrtc-adapter@8.2.3: + resolution: {integrity: sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==} + engines: {node: '>=6.0.0', npm: '>=3.10.0'} + dependencies: + sdp: 3.2.0 + dev: false + /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 3e8a2b87..749595e6 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -434,6 +434,51 @@ "description": "Remove Google login buttons and links from the interface", "name": "No Google Login" }, + "music-together": { + "name": "Music Together [Beta]", + "description": "Share a playlist with others. When the host plays a song, everyone else will hear the same song", + "internal": { + "unknown-user": "Unknown User", + "track-source": "Track Source", + "save": "Save" + }, + "menu": { + "disconnect": "Disconnect Music Together", + "click-to-copy-id": "Copy Host ID", + "close": "Close Music Together", + "host": "Music Together Host", + "join": "Join Music Together", + "connected-users": "Connected Users", + "empty-user": "No connected users", + "set-permission": "Change Control Permission", + "status": { + "disconnected": "Disconnected", + "host": "Connected as Host", + "guest": "Connected as Guest" + }, + "permission": { + "host-only": "Host Only", + "playlist": "Playlist Control", + "all": "All Control" + } + }, + "dialog": { + "enter-host": "Enter Host ID" + }, + "toast": { + "add-song-failed": "Failed to add song", + "remove-song-failed": "Failed to remove song", + "closed": "Music Together closed", + "disconnected": "Music Together disconnected", + "id-copied": "Host ID copied to clipboard", + "host-failed": "Failed to host Music Together", + "joined": "Joined Music Together", + "user-connected": "{{name}} joined Music Together", + "user-disconnected": "{{name}} left Music Together", + "join-failed": "Failed to join Music Together", + "permission-changed": "Music Together permission changed to \"{{permission}}\"" + } + }, "notifications": { "description": "Display a notification when a song starts playing (interactive notifications are available on Windows)", "menu": { diff --git a/src/i18n/resources/ko.json b/src/i18n/resources/ko.json index 1c60d9be..fad8dc88 100644 --- a/src/i18n/resources/ko.json +++ b/src/i18n/resources/ko.json @@ -430,6 +430,51 @@ "fetched-lyrics": "Genius에서 가사 불러옴" } }, + "music-together": { + "name": "Music Together [베타]", + "description": "여러명과 함께 플레이리스트를 공유합니다. 호스트가 음악을 재생하면, 다른 사용자들도 같은 노래를 들을 수 있습니다", + "internal": { + "unknown-user": "알 수 없는 사용자", + "track-source": "재생 중인 트랙 출처", + "save": "저장" + }, + "menu": { + "disconnect": "Music Together 연결 끊기", + "click-to-copy-id": "호스트 아이디 복사", + "close": "Music Together 닫기", + "host": "Music Together 호스트", + "join": "Music Together 참여", + "connected-users": "연결된 사용자", + "empty-user": "연결된 사용자 없음", + "set-permission": "제어 권한 변경", + "status": { + "disconnected": "연결 끊김", + "host": "호스트로 연결됨", + "guest": "게스트로 연결됨" + }, + "permission": { + "host-only": "호스트만 제어 가능", + "playlist": "재생목록 제어 가능", + "all": "모두 제어 가능" + } + }, + "dialog": { + "enter-host": "호스트 아이디를 입력하세요" + }, + "toast": { + "add-song-failed": "노래 추가 실패", + "remove-song-failed": "노래 제거 실패", + "closed": "Music Together가 닫혔습니다", + "disconnected": "Music Together 연결이 끊어졌습니다", + "id-copied": "호스트 아이디가 클립보드에 복사되었습니다", + "host-failed": "Music Together를 열 수 없습니다", + "joined": "Music Together에 참여했습니다", + "user-connected": "{{name}}님이 Music Together에 참여했습니다", + "user-disconnected": "{{name}}님이 Music Together에서 나갔습니다", + "join-failed": "Music Together에 참여할 수 없습니다", + "permission-changed": "Music Together 제어 권한이 \"{{permission}}\" 변경되었습니다" + } + }, "navigation": { "description": "브라우저에서처럼, UI에 직접 통합된 앞으로/뒤로 탐색하는 화살표", "name": "탐색" diff --git a/src/plugins/adblocker/index.ts b/src/plugins/adblocker/index.ts index 612f1797..bfc35f93 100644 --- a/src/plugins/adblocker/index.ts +++ b/src/plugins/adblocker/index.ts @@ -109,7 +109,7 @@ export default createPlugin({ }, }, preload: { - script: 'window.JSON = window._proxyJson; window._proxyJson = undefined; window.Response = window._proxyResponse; window._proxyResponse = undefined; 0', + script: 'window.JSON.parse = window._proxyJsonParse; window._proxyJsonParse = undefined; window.Response.prototype.json = window._proxyResponseJson; window._proxyResponseJson = undefined; 0', async start({ getConfig }) { const config = await getConfig(); diff --git a/src/plugins/adblocker/injectors/inject.js b/src/plugins/adblocker/injectors/inject.js index c3e6e87c..2327af71 100644 --- a/src/plugins/adblocker/injectors/inject.js +++ b/src/plugins/adblocker/injectors/inject.js @@ -32,37 +32,17 @@ export const inject = (contextBridge) => { return o; }; - contextBridge.exposeInMainWorld('_proxyJson', { - parse: new Proxy(JSON.parse, { - apply() { - return pruner(Reflect.apply(...arguments)); - }, - }), - stringify: JSON.stringify, - [Symbol.toStringTag]: JSON[Symbol.toStringTag], - }); + contextBridge.exposeInMainWorld('_proxyJsonParse', new Proxy(JSON.parse, { + apply() { + return pruner(Reflect.apply(...arguments)); + }, + })); - const withPrototype = (obj) => { - const protos = Object.getPrototypeOf(obj); - for (const [key, value] of Object.entries(protos)) { - if (Object.prototype.hasOwnProperty.call(obj, key)) continue; - if (typeof value === 'function') { - obj[key] = function (...args) { - return value.call(obj, ...args); - } - } else { - obj[key] = value; - } - } - return obj; - }; - - Response.prototype.json = new Proxy(Response.prototype.json, { + contextBridge.exposeInMainWorld('_proxyResponseJson', new Proxy(Response.prototype.json, { apply() { return Reflect.apply(...arguments).then((o) => pruner(o)); }, - }); - contextBridge.exposeInMainWorld('_proxyResponse', withPrototype(Response)); + })); } (function () { diff --git a/src/plugins/music-together/connection.ts b/src/plugins/music-together/connection.ts new file mode 100644 index 00000000..4248ab54 --- /dev/null +++ b/src/plugins/music-together/connection.ts @@ -0,0 +1,149 @@ +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]; + +type PromiseUtil = { + promise: Promise; + resolve: (id: T) => void; + reject: (err: unknown) => void; +} + +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 connections: Record = {}; + + private waitOpen: PromiseUtil = {} as PromiseUtil; + private listeners: ConnectionListener[] = []; + private connectionListeners: ((connection?: DataConnection) => void)[] = []; + + constructor() { + this.peer = new Peer({ debug: 0 }); + + this.waitOpen.promise = new Promise((resolve, reject) => { + this.waitOpen.resolve = resolve; + this.waitOpen.reject = reject; + }); + + this.peer.on('open', (id) => { + this._mode = 'host'; + this.waitOpen.resolve(id); + }); + this.peer.on('connection', (conn) => { + this._mode = 'host'; + this.registerConnection(conn); + }); + this.peer.on('error', (err) => { + this._mode = 'disconnected'; + + this.waitOpen.reject(err); + this.connectionListeners.forEach((listener) => listener()); + console.log(err); + }); + } + + /* public */ + async waitForReady() { + return this.waitOpen.promise; + } + + async connect(id: string) { + this._mode = 'guest'; + const conn = this.peer.connect(id); + await this.registerConnection(conn); + return conn; + } + + async disconnect() { + if (this._mode === 'disconnected') throw new Error('Already disconnected'); + + this._mode = 'disconnected'; + this.connections = {}; + this.peer.destroy(); + } + + /* utils */ + public get id() { + return this.peer.id; + } + + public get mode() { + return this._mode; + } + + public getConnections() { + return Object.values(this.connections); + } + + public async broadcast(type: Event, payload: ConnectionEventMap[Event]) { + await Promise.all( + this.getConnections().map((conn) => conn.send({ type, payload })) + ); + } + + public on(listener: ConnectionListener) { + this.listeners.push(listener); + } + + public onConnections(listener: (connections?: DataConnection) => void) { + this.connectionListeners.push(listener); + } + + /* privates */ + private async registerConnection(conn: DataConnection) { + return new Promise((resolve, reject) => { + this.peer.once('error', (err) => { + this._mode = 'disconnected'; + + reject(err); + this.connectionListeners.forEach((listener) => listener()); + }); + + conn.on('open', () => { + this.connections[conn.connectionId] = conn; + resolve(conn); + this.connectionListeners.forEach((listener) => listener(conn)); + + conn.on('data', (data) => { + if (!data || typeof data !== 'object' || !('type' in data) || !('payload' in data) || !data.type) { + console.warn('Music Together: Invalid data', data); + return; + } + + for (const listener of this.listeners) { + listener(data as ConnectionEventUnion, conn); + } + }); + }); + + const onClose = (err?: Error) => { + if (err) reject(err); + + delete this.connections[conn.connectionId]; + this.connectionListeners.forEach((listener) => listener(conn)); + }; + conn.on('error', onClose); + conn.on('close', onClose); + }); + } +} diff --git a/src/plugins/music-together/element.ts b/src/plugins/music-together/element.ts new file mode 100644 index 00000000..efd25f1e --- /dev/null +++ b/src/plugins/music-together/element.ts @@ -0,0 +1,138 @@ +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 new file mode 100644 index 00000000..374bebf8 --- /dev/null +++ b/src/plugins/music-together/icons/connect.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/music-together/icons/key.svg b/src/plugins/music-together/icons/key.svg new file mode 100644 index 00000000..cfa71c8b --- /dev/null +++ b/src/plugins/music-together/icons/key.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/plugins/music-together/icons/music-cast.svg b/src/plugins/music-together/icons/music-cast.svg new file mode 100644 index 00000000..e0e075ad --- /dev/null +++ b/src/plugins/music-together/icons/music-cast.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/music-together/icons/off.svg b/src/plugins/music-together/icons/off.svg new file mode 100644 index 00000000..9505e203 --- /dev/null +++ b/src/plugins/music-together/icons/off.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/plugins/music-together/icons/tune.svg b/src/plugins/music-together/icons/tune.svg new file mode 100644 index 00000000..fb50c380 --- /dev/null +++ b/src/plugins/music-together/icons/tune.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/music-together/index.ts b/src/plugins/music-together/index.ts new file mode 100644 index 00000000..813ce0e2 --- /dev/null +++ b/src/plugins/music-together/index.ts @@ -0,0 +1,680 @@ +import prompt from 'custom-electron-prompt'; + +import { t } from '@/i18n'; +import { createPlugin } from '@/utils'; +import promptOptions from '@/providers/prompt-options'; + +import { AppAPI, getDefaultProfile, Permission, Profile, VideoData } from './types'; +import { Queue } from './queue'; +import { Connection, ConnectionEventUnion } from './connection'; +import { createHostPopup } from './ui/host'; +import { createGuestPopup } from './ui/guest'; +import { createSettingPopup } from './ui/setting'; + +import settingHTML from './templates/setting.html?raw'; +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 { DataConnection } from 'peerjs'; + +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({ + name: () => t('plugins.music-together.name'), + description: () => t('plugins.music-together.description'), + restartNeeded: false, + config: { + enabled: true + }, + stylesheets: [style], + backend: { + async start({ ipc }) { + ipc.handle('music-together:prompt', async (title: string, label: string) => prompt({ + title, + label, + type: 'input', + ...promptOptions() + })); + } + }, + renderer: { + connection: null as Connection | null, + ipc: null as RendererContext['ipc'] | null, + + api: null as (HTMLElement & AppAPI) | null, + queue: null as Queue | null, + playerApi: null as YoutubePlayer | null, + showPrompt: (async () => null) as ((title: string, label: string) => Promise), + + elements: {} as { + setting: HTMLElement; + icon: SVGElement; + spinner: HTMLElement; + }, + popups: {} as { + host: ReturnType; + guest: ReturnType; + setting: ReturnType; + }, + stateInterval: null as number | null, + updateNext: false, + ignoreChange: false, + rollbackInjector: null as (() => void) | null, + + me: null as Omit | null, + profiles: {} as Record, + permission: 'playlist' as Permission, + + /* events */ + videoChangeListener(event: CustomEvent) { + if (event.detail.name === 'dataloaded' || this.updateNext) { + if (this.connection?.mode === 'host') { + const videoList: VideoData[] = this.queue?.flatItems.map((it: any) => ({ + 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: any) => ({ + 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?.openToast(t('plugins.music-together.toast.disconnected')); + this.onStop(); + return; + } + + if (!connection.open) { + this.api?.openToast(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; + + await 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?.openToast(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?.openToast(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': { + await this.queue?.removeVideo(event.payload.index); + break; + } + case 'MOVE_SONG': { + await 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?.openToast(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 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 = null; + } + + 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 void + }>('ytmusic-settings-button'); + + accountButton?.onButtonTap(); + setTimeout(() => { + accountButton?.onButtonTap(); + const renderer = document.querySelector('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, + 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); + 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?.openToast(t('plugins.music-together.toast.closed')); + hostPopup.dismiss(); + } + + if (id === 'music-together-copy-id') { + navigator.clipboard.writeText(this.connection?.id ?? ''); + + this.api?.openToast(t('plugins.music-together.toast.id-copied')); + 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?.openToast(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?.openToast(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 ?? ''); + this.api?.openToast(t('plugins.music-together.toast.id-copied')); + hostPopup.showAtAnchor(setting); + } else { + this.api?.openToast(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?.openToast(t('plugins.music-together.toast.joined')); + guestPopup.showAtAnchor(setting); + } else { + this.api?.openToast(t('plugins.music-together.toast.join-failed')); + } + } + } + }); + this.popups = { + host: hostPopup, + guest: guestPopup, + setting: settingPopup + }; + setting.addEventListener('click', async () => { + 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); + } + } +}); diff --git a/src/plugins/music-together/queue/client.ts b/src/plugins/music-together/queue/client.ts new file mode 100644 index 00000000..7ff8a11a --- /dev/null +++ b/src/plugins/music-together/queue/client.ts @@ -0,0 +1,40 @@ +import { SHA1Hash } from './sha1hash'; + +export const extractToken = (cookie = document.cookie) => cookie.match(/SAPISID=([^;]+);/)?.[1] ?? cookie.match(/__Secure\-3PAPISID=([^;]+);/)?.[1]; + +export const getHash = (papisid: string, millis = Date.now(), origin: string = 'https://music.youtube.com') => { + const hash = SHA1Hash(); + hash.update(`${millis} ${papisid} ${origin}`); + return hash.digestString().toLowerCase(); +}; + +export const getAuthorizationHeader = (papisid: string, millis = Date.now(), origin: string = 'https://music.youtube.com') => { + return `SAPISIDHASH ${millis}_${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 new file mode 100644 index 00000000..cadd6a98 --- /dev/null +++ b/src/plugins/music-together/queue/index.ts @@ -0,0 +1 @@ +export * from './queue'; diff --git a/src/plugins/music-together/queue/queue.ts b/src/plugins/music-together/queue/queue.ts new file mode 100644 index 00000000..b33104d9 --- /dev/null +++ b/src/plugins/music-together/queue/queue.ts @@ -0,0 +1,429 @@ +import { getMusicQueueRenderer } from './song'; +import { mapQueueItem } from './utils'; + +import type { Profile, QueueAPI, VideoData } from '../types'; +import { ConnectionEventUnion } from '@/plugins/music-together/connection'; +import { t } from '@/i18n'; + +const getHeaderPayload = (() => { + let payload: unknown = 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?: HTMLElement & QueueAPI; + getProfile: (id: string) => Profile | undefined; +} +export type QueueEventListener = (event: ConnectionEventUnion) => void; + +export class Queue { + private queue: (HTMLElement & QueueAPI) | null = null; + private originalDispatch: ((obj: { + type: string; + payload?: unknown; + }) => void) | null = null; + private internalDispatch = false; + private ignoreFlag = false; + private listeners: QueueEventListener[] = []; + private owner: Profile | null = null; + private 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?.store.getState().queue.items).findIndex(Boolean) ?? 0; + } + + get rawItems() { + return this.queue?.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.store.getState().queue.nextQueueItemId, + index: index ?? this.queue.store.getState().queue.items.length ?? 0, + items, + shuffleEnabled: false, + shouldAssignIds: true + } + }); + this.internalDispatch = false; + setTimeout(() => { + this.initQueue(); + this.syncQueueOwner(); + }, 0); + + return true; + } + + async 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.store.dispatch = this.originalDispatch; + } + + injection() { + if (!this.queue) { + console.error('Queue is not initialized!'); + return; + } + + this.originalDispatch = this.queue.store.dispatch; + this.queue.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: any) => ({ + videoId: it.videoId, + ownerId: this.owner!.id + } satisfies VideoData), (event.payload as any).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 any).items.length === 1) { + this.broadcast({ // add playlist + type: 'ADD_SONGS', + payload: { + // index: (event.payload as any).index, + videoList: mapQueueItem((it: any) => ({ + videoId: it.videoId, + ownerId: this.owner!.id + } satisfies VideoData), (event.payload as any).items) + } + }); + } + + return; + } + + if (event.type === 'MOVE_ITEM') { + this.broadcast({ + type: 'MOVE_SONG', + payload: { + fromIndex: (event.payload as any).fromIndex, + toIndex: (event.payload as any).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 === 'INACTIVE' && this.videoList.length > 0) { + return; + } + } + if (event.type === 'HAS_SHOWN_AUTOPLAY') return; + if (event.type === 'ADD_AUTOMIX_ITEMS') return; + } + + const fakeContext = { + ...this.queue, + store: { + ...this.queue.store, + dispatch: this.originalDispatch + } + }; + this.originalDispatch!.call(fakeContext, event); + }; + } + + /* sync */ + async 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.store.getState().queue.nextQueueItemId, + shouldAssignIds: true, + currentIndex: -1 + } + }); + this.internalDispatch = false; + setTimeout(() => { + this.initQueue(); + this.syncQueueOwner(); + }, 0); + + return true; + } + + async syncQueueOwner() { + const allQueue = document.querySelectorAll('#queue'); + + allQueue.forEach((queue) => { + const list = Array.from(queue?.querySelectorAll('ytmusic-player-queue-item') ?? []); + + list.forEach((item, index) => { + 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 new file mode 100644 index 00000000..9806b18b --- /dev/null +++ b/src/plugins/music-together/queue/sha1hash.ts @@ -0,0 +1,117 @@ +export function SHA1Hash(): { + reset: () => void, + update: (message: string | number[], length?: number) => void, + digest: () => number[], + digestString: () => string +} { + let hash: number[]; + + function initialize(): void { + hash = [1732584193, 4023233417, 2562383102, 271733878, 3285377520]; + totalLength = currentLength = 0; + } + + function processBlock(block: number[]): void { + let words: number[] = []; + for (let i = 0; i < 64; i += 4) { + words[i / 4] = block[i] << 24 | block[i + 1] << 16 | block[i + 2] << 8 | block[i + 3]; + } + + for (let i = 16; i < 80; i++) { + let temp = words[i - 3] ^ words[i - 8] ^ words[i - 14] ^ words[i - 16]; + words[i] = (temp << 1 | temp >>> 31) & 4294967295; + } + + let a = hash[0], + b = hash[1], + c = hash[2], + d = hash[3], + e = hash[4]; + for (let i = 0; i < 80; i++) { + let f, k; + if (i < 20) { + f = d ^ b & (c ^ d); + k = 1518500249; + } else if (i < 40) { + f = b ^ c ^ d; + k = 1859775393; + } else if (i < 60) { + f = b & c | d & (b | c); + k = 2400959708; + } else { + f = b ^ c ^ d; + k = 3395469782; + } + let temp = ((a << 5 | a >>> 27) & 4294967295) + f + e + k + words[i] & 4294967295; + e = d; + d = c; + c = (b << 30 | b >>> 2) & 4294967295; + b = a; + a = temp; + } + hash[0] = hash[0] + a & 4294967295; + hash[1] = hash[1] + b & 4294967295; + hash[2] = hash[2] + c & 4294967295; + hash[3] = hash[3] + d & 4294967295; + hash[4] = hash[4] + e & 4294967295; + } + + function update(message: string | number[], length?: number): void { + if ('string' === typeof message) { + message = unescape(encodeURIComponent(message)); + let bytes: number[] = []; + for (let i = 0, len = message.length; i < len; ++i) + bytes.push(message.charCodeAt(i)); + message = bytes; + } + length || (length = message.length); + let i = 0; + if (0 == currentLength) + for (; i + 64 < length;) + processBlock(message.slice(i, i + 64)), + i += 64, + totalLength += 64; + for (; i < length;) + if (buffer[currentLength++] = message[i++], + totalLength++, + 64 == currentLength) + for (currentLength = 0, + processBlock(buffer); i + 64 < length;) + processBlock(message.slice(i, i + 64)), + i += 64, + totalLength += 64; + } + + function finalize(): number[] { + let result: number[] = [] + , bits = 8 * totalLength; + if (currentLength < 56) + update(padding, 56 - currentLength); + else + update(padding, 64 - (currentLength - 56)); + for (let i = 63; i >= 56; i--) + buffer[i] = bits & 255, + bits >>>= 8; + processBlock(buffer); + for (let i = 0; i < 5; i++) + for (let j = 24; j >= 0; j -= 8) + result.push(hash[i] >> j & 255); + return result; + } + + let buffer: number[] = [], padding: number[] = [128], totalLength: number, currentLength: number; + for (let i = 1; i < 64; ++i) + padding[i] = 0; + initialize(); + return { + reset: initialize, + update: update, + digest: finalize, + digestString: function(): string { + let hash = finalize(), hex = ''; + for (let i = 0; i < hash.length; i++) + hex += '0123456789ABCDEF'.charAt(Math.floor(hash[i] / 16)) + '0123456789ABCDEF'.charAt(hash[i] % 16); + return hex; + } + }; +} diff --git a/src/plugins/music-together/queue/song.ts b/src/plugins/music-together/queue/song.ts new file mode 100644 index 00000000..7b0acb21 --- /dev/null +++ b/src/plugins/music-together/queue/song.ts @@ -0,0 +1,48 @@ +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: 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 new file mode 100644 index 00000000..be807e49 --- /dev/null +++ b/src/plugins/music-together/queue/utils.ts @@ -0,0 +1,15 @@ +export const mapQueueItem = (map: (item: any | null) => T, array: any[]): T[] => array + .map((item) => { + if ('playlistPanelVideoWrapperRenderer' in item) { + const keys = Object.keys(item.playlistPanelVideoWrapperRenderer.primaryRenderer); + return item.playlistPanelVideoWrapperRenderer.primaryRenderer[keys[0]]; + } + if ('playlistPanelVideoRenderer' in item) { + return item.playlistPanelVideoRenderer; + } + + console.error('Music Together: Unknown item', item); + return null; + }) + .map(map); + diff --git a/src/plugins/music-together/style.css b/src/plugins/music-together/style.css new file mode 100644 index 00000000..2336eebc --- /dev/null +++ b/src/plugins/music-together/style.css @@ -0,0 +1,160 @@ +.music-together-button { + display: inline-flex; + + cursor: pointer; + margin-left: 8px; + + & svg { + width: 24px; + height: 24px; + fill: rgba(255, 255, 255, .5); + } + + &:hover svg:hover { + fill: #fff; + } +} +#right-content > .music-together-divider { + width: 1px; + height: 26px; + margin-left: 16px; + margin-right: 8px; +} + +.music-together-divider { + background-color: rgba(255, 255, 255, .15); +} +.music-together-divider.horizontal { + width: 100%; + height: 1px; +} +.music-together-divider.vertical { + width: 1px; + height: 100%; +} + +.music-together-tool { + position: absolute; + + display: flex; + align-items: center; + gap: 8px; + + opacity: 0; + translate: 50%; + pointer-events: none; + + transition: all 0.225s ease-out; + + &.open { + position: unset; + opacity: 1; + translate: 0; + pointer-events: all; + } +} + +.music-together-spinner { + +} + +.music-together-popup { + position: fixed; + + z-index: 1000; +} +.music-together-popup-container { + border-radius: 10px !important; +} + +.music-together-item { + 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)); + } +} + +.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; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + gap: 8px; + padding-top: 16px; + font-size: 14px; +} +.music-together-empty { + width: 100%; + + font-size: 14px; + color: rgba(255, 255, 255, .5); + text-align: center; +} + +.music-together-owner { + width: 24px; + height: 24px; + flex-shrink: 0; + border-radius: 50%; + + margin-left: 8px; +} + +.music-together-name { + display: none; + + color: #fff; + font-size: 14px; + margin-left: 8px; +} + +ytmusic-player-queue-item:hover .music-together-name { + display: unset; +} diff --git a/src/plugins/music-together/templates/item.html b/src/plugins/music-together/templates/item.html new file mode 100644 index 00000000..1c5a43d7 --- /dev/null +++ b/src/plugins/music-together/templates/item.html @@ -0,0 +1,8 @@ +
+
+ +
+
+ +
+
diff --git a/src/plugins/music-together/templates/popup.html b/src/plugins/music-together/templates/popup.html new file mode 100644 index 00000000..e2843846 --- /dev/null +++ b/src/plugins/music-together/templates/popup.html @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/src/plugins/music-together/templates/setting.html b/src/plugins/music-together/templates/setting.html new file mode 100644 index 00000000..b6d6897b --- /dev/null +++ b/src/plugins/music-together/templates/setting.html @@ -0,0 +1,7 @@ +
+ + + + +
+
diff --git a/src/plugins/music-together/templates/status.html b/src/plugins/music-together/templates/status.html new file mode 100644 index 00000000..3d85e073 --- /dev/null +++ b/src/plugins/music-together/templates/status.html @@ -0,0 +1,23 @@ +
+
+ +
+ + + + + + + +
+
+
+
+ +
+
+ + + +
+
diff --git a/src/plugins/music-together/types.ts b/src/plugins/music-together/types.ts new file mode 100644 index 00000000..157a85f2 --- /dev/null +++ b/src/plugins/music-together/types.ts @@ -0,0 +1,54 @@ +import { YoutubePlayer } from '@/types/youtube-player'; +type StoreState = any; +type Store = { + dispatch: (obj: { + type: string; + payload?: unknown; + }) => void; + + getState: () => StoreState; + replaceReducer: (param1: unknown) => unknown; + subscribe: (callback: () => void) => unknown; +}; +export type QueueAPI = { + dispatch(obj: { + type: string; + payload?: unknown; + }): void; + getItems(): unknown[]; + store: Store; + continuation?: string; + autoPlaying?: boolean; +}; +export type AppAPI = { + queue_: QueueAPI; + playerApi_: YoutubePlayer; + openToast: (message: string) => void; + + // TODO: Add more +}; + + + +export type Profile = { + id: string; + handleId: string; + name: string; + thumbnail: string; +}; +export type VideoData = { + videoId: string; + ownerId: string; +}; +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` + }; +}; diff --git a/src/plugins/music-together/ui/guest.ts b/src/plugins/music-together/ui/guest.ts new file mode 100644 index 00000000..bbbff37e --- /dev/null +++ b/src/plugins/music-together/ui/guest.ts @@ -0,0 +1,43 @@ +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 new file mode 100644 index 00000000..2a269e4b --- /dev/null +++ b/src/plugins/music-together/ui/host.ts @@ -0,0 +1,60 @@ +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 new file mode 100644 index 00000000..aede311e --- /dev/null +++ b/src/plugins/music-together/ui/setting.ts @@ -0,0 +1,49 @@ +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 new file mode 100644 index 00000000..9119ed8d --- /dev/null +++ b/src/plugins/music-together/ui/status.ts @@ -0,0 +1,82 @@ +import { ElementFromHtml } from '@/plugins/utils/renderer'; +import statusHTML from '../templates/status.html?raw'; +import { t } from '@/i18n'; +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/plugins/utils/renderer/html.ts b/src/plugins/utils/renderer/html.ts index 9b9e85ae..aa000731 100644 --- a/src/plugins/utils/renderer/html.ts +++ b/src/plugins/utils/renderer/html.ts @@ -1,4 +1,8 @@ -// Creates a DOM element from an HTML string +/** + * Creates a DOM element from an HTML string + * @param html The HTML string + * @returns The DOM element + */ export const ElementFromHtml = (html: string): HTMLElement => { const template = document.createElement('template'); html = html.trim(); // Never return a text node of whitespace as the result @@ -6,3 +10,14 @@ export const ElementFromHtml = (html: string): HTMLElement => { return template.content.firstElementChild as HTMLElement; }; + +/** + * Creates a DOM element from a src string + * @param src The source of the image + * @returns The image element + */ +export const ImageElementFromSrc = (src: string): HTMLImageElement => { + const image = document.createElement('img'); + image.src = src; + return image; +}; diff --git a/src/types/player-api-events.ts b/src/types/player-api-events.ts index b9baddb8..9575818b 100644 --- a/src/types/player-api-events.ts +++ b/src/types/player-api-events.ts @@ -257,4 +257,5 @@ export interface PlayerAPIEvents { videodatachange: { value: VideoDataChangeValue; } & ({ name: 'dataloaded' } | { name: 'dataupdated ' }); + onStateChange: number; } diff --git a/src/types/youtube-player.ts b/src/types/youtube-player.ts index 28c4e2e0..44a29225 100644 --- a/src/types/youtube-player.ts +++ b/src/types/youtube-player.ts @@ -262,7 +262,23 @@ export interface YoutubePlayer { showControls: () => void; hideControls: () => void; cancelPlayback: () => void; - getProgressState: () => Return; + getProgressState: () => { + airingEnd: number; + airingStart: number; + allowSeeking: boolean; + clipEnd: number; + clipStart: number; + current: number; + displayedStart: number; + duration: number; + ingestionTime: number; + isAtLiveHead: boolean; + loaded: number; + offset: number; + seekableEnd: number; + seekableStart: number; + viewerLivestreamJoinMediaTime: number; + }; isInline: () => boolean; setInline: (isInline: boolean) => void; setLoopVideo: (value: boolean) => void; @@ -320,6 +336,10 @@ export interface YoutubePlayer { getVolume: () => number; seekTo: (seconds: number) => void; getPlayerMode: () => Return; + /** + * 1: playing + * 2: paused + */ getPlayerState: () => number; getAvailablePlaybackRates: () => number[]; getPlaybackQuality: () => string; diff --git a/src/youtube-music.d.ts b/src/youtube-music.d.ts index 8ee11c56..b5ace4e1 100644 --- a/src/youtube-music.d.ts +++ b/src/youtube-music.d.ts @@ -15,6 +15,11 @@ declare module '*.svg?inline' { export default base64; } +declare module '*.svg?raw' { + const html: string; + + export default html; +} declare module '*.png' { const element: HTMLImageElement;