mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-17 05:02:06 +00:00
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 <shlee1503@naver.com>
This commit is contained in:
@ -167,6 +167,7 @@
|
|||||||
"keyboardevents-areequal": "0.2.2",
|
"keyboardevents-areequal": "0.2.2",
|
||||||
"node-html-parser": "6.1.12",
|
"node-html-parser": "6.1.12",
|
||||||
"node-id3": "0.2.6",
|
"node-id3": "0.2.6",
|
||||||
|
"peerjs": "1.5.2",
|
||||||
"serve": "14.2.1",
|
"serve": "14.2.1",
|
||||||
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
|
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
|
||||||
"ts-morph": "21.0.1",
|
"ts-morph": "21.0.1",
|
||||||
|
|||||||
116
pnpm-lock.yaml
generated
116
pnpm-lock.yaml
generated
@ -114,6 +114,9 @@ dependencies:
|
|||||||
node-id3:
|
node-id3:
|
||||||
specifier: 0.2.6
|
specifier: 0.2.6
|
||||||
version: 0.2.6
|
version: 0.2.6
|
||||||
|
peerjs:
|
||||||
|
specifier: 1.5.2
|
||||||
|
version: 1.5.2
|
||||||
serve:
|
serve:
|
||||||
specifier: 14.2.1
|
specifier: 14.2.1
|
||||||
version: 14.2.1
|
version: 14.2.1
|
||||||
@ -465,6 +468,54 @@ packages:
|
|||||||
to-fast-properties: 2.0.0
|
to-fast-properties: 2.0.0
|
||||||
dev: true
|
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:
|
/@cliqz/adblocker-content@1.26.12:
|
||||||
resolution: {integrity: sha512-4LWW3kntWuTDo10u24uuk0GmTzegkw9cZ8eDBzzDvHOtRVRMUv4fuoaWCwnB6UpA1VH7iU5nCbRlXNvjnnUA2Q==}
|
resolution: {integrity: sha512-4LWW3kntWuTDo10u24uuk0GmTzegkw9cZ8eDBzzDvHOtRVRMUv4fuoaWCwnB6UpA1VH7iU5nCbRlXNvjnnUA2Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -953,6 +1004,11 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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:
|
/@nodelib/fs.scandir@2.1.5:
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@ -2108,6 +2164,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==}
|
resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==}
|
||||||
dev: true
|
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:
|
/chalk-template@0.4.0:
|
||||||
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
|
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -3267,6 +3345,10 @@ packages:
|
|||||||
through: 2.3.8
|
through: 2.3.8
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/eventemitter3@4.0.7:
|
||||||
|
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/execa@5.1.1:
|
/execa@5.1.1:
|
||||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -4704,6 +4786,13 @@ packages:
|
|||||||
formdata-polyfill: 4.0.10
|
formdata-polyfill: 4.0.10
|
||||||
dev: false
|
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:
|
/node-gyp-build@4.7.1:
|
||||||
resolution: {integrity: sha512-wTSrZ+8lsRRa3I3H8Xr65dLWSgCvY2l4AOnaeKdPA9TB/WYMPaTcrzf3rXvFoVvjKNVnu0CcWSx54qq9GKRUYg==}
|
resolution: {integrity: sha512-wTSrZ+8lsRRa3I3H8Xr65dLWSgCvY2l4AOnaeKdPA9TB/WYMPaTcrzf3rXvFoVvjKNVnu0CcWSx54qq9GKRUYg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -5024,6 +5113,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||||
dev: false
|
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:
|
/pend@1.2.0:
|
||||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||||
|
|
||||||
@ -5373,6 +5478,10 @@ packages:
|
|||||||
/sax@1.3.0:
|
/sax@1.3.0:
|
||||||
resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==}
|
resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==}
|
||||||
|
|
||||||
|
/sdp@3.2.0:
|
||||||
|
resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/selderee@0.11.0:
|
/selderee@0.11.0:
|
||||||
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
|
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -6148,6 +6257,13 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
dev: false
|
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:
|
/which-boxed-primitive@1.0.2:
|
||||||
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
|
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@ -434,6 +434,51 @@
|
|||||||
"description": "Remove Google login buttons and links from the interface",
|
"description": "Remove Google login buttons and links from the interface",
|
||||||
"name": "No Google Login"
|
"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": {
|
"notifications": {
|
||||||
"description": "Display a notification when a song starts playing (interactive notifications are available on Windows)",
|
"description": "Display a notification when a song starts playing (interactive notifications are available on Windows)",
|
||||||
"menu": {
|
"menu": {
|
||||||
|
|||||||
@ -430,6 +430,51 @@
|
|||||||
"fetched-lyrics": "Genius에서 가사 불러옴"
|
"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": {
|
"navigation": {
|
||||||
"description": "브라우저에서처럼, UI에 직접 통합된 앞으로/뒤로 탐색하는 화살표",
|
"description": "브라우저에서처럼, UI에 직접 통합된 앞으로/뒤로 탐색하는 화살표",
|
||||||
"name": "탐색"
|
"name": "탐색"
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export default createPlugin({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
preload: {
|
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 }) {
|
async start({ getConfig }) {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
|
|
||||||
|
|||||||
@ -32,37 +32,17 @@ export const inject = (contextBridge) => {
|
|||||||
return o;
|
return o;
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('_proxyJson', {
|
contextBridge.exposeInMainWorld('_proxyJsonParse', new Proxy(JSON.parse, {
|
||||||
parse: new Proxy(JSON.parse, {
|
apply() {
|
||||||
apply() {
|
return pruner(Reflect.apply(...arguments));
|
||||||
return pruner(Reflect.apply(...arguments));
|
},
|
||||||
},
|
}));
|
||||||
}),
|
|
||||||
stringify: JSON.stringify,
|
|
||||||
[Symbol.toStringTag]: JSON[Symbol.toStringTag],
|
|
||||||
});
|
|
||||||
|
|
||||||
const withPrototype = (obj) => {
|
contextBridge.exposeInMainWorld('_proxyResponseJson', new Proxy(Response.prototype.json, {
|
||||||
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, {
|
|
||||||
apply() {
|
apply() {
|
||||||
return Reflect.apply(...arguments).then((o) => pruner(o));
|
return Reflect.apply(...arguments).then((o) => pruner(o));
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
contextBridge.exposeInMainWorld('_proxyResponse', withPrototype(Response));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
149
src/plugins/music-together/connection.ts
Normal file
149
src/plugins/music-together/connection.ts
Normal file
@ -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<string, Profile> } | undefined;
|
||||||
|
SYNC_QUEUE: { videoList: VideoData[] } | undefined;
|
||||||
|
SYNC_PROGRESS: { progress?: number; state?: number; index?: number; } | undefined;
|
||||||
|
PERMISSION: Permission | undefined;
|
||||||
|
};
|
||||||
|
export type ConnectionEventUnion = {
|
||||||
|
[Event in keyof ConnectionEventMap]: {
|
||||||
|
type: Event;
|
||||||
|
payload: ConnectionEventMap[Event];
|
||||||
|
after?: ConnectionEventUnion[];
|
||||||
|
};
|
||||||
|
}[keyof ConnectionEventMap];
|
||||||
|
|
||||||
|
type PromiseUtil<T> = {
|
||||||
|
promise: Promise<T>;
|
||||||
|
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<string, DataConnection> = {};
|
||||||
|
|
||||||
|
private waitOpen: PromiseUtil<string> = {} as PromiseUtil<string>;
|
||||||
|
private listeners: ConnectionListener[] = [];
|
||||||
|
private connectionListeners: ((connection?: DataConnection) => void)[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.peer = new Peer({ debug: 0 });
|
||||||
|
|
||||||
|
this.waitOpen.promise = new Promise<string>((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<Event extends keyof ConnectionEventMap>(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<DataConnection>((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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/plugins/music-together/element.ts
Normal file
138
src/plugins/music-together/element.ts
Normal file
@ -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<HTMLElement>('.music-together-popup-container')!;
|
||||||
|
const items = props.data
|
||||||
|
.map((props) => {
|
||||||
|
if (props.type === 'item') return {
|
||||||
|
type: 'item' as const,
|
||||||
|
...ItemRenderer(props),
|
||||||
|
};
|
||||||
|
if (props.type === 'divider') return {
|
||||||
|
type: 'divider' as const,
|
||||||
|
element: ElementFromHtml('<div class="music-together-divider horizontal"></div>'),
|
||||||
|
};
|
||||||
|
if (props.type === 'custom') return {
|
||||||
|
type: 'custom' as const,
|
||||||
|
element: props.element,
|
||||||
|
};
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
container.append(...items.map(({ element }) => element));
|
||||||
|
popup.style.setProperty('opacity', '0');
|
||||||
|
popup.style.setProperty('pointer-events', 'none');
|
||||||
|
|
||||||
|
document.body.append(popup);
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: popup,
|
||||||
|
container,
|
||||||
|
items,
|
||||||
|
|
||||||
|
show(x: number, y: number, anchor?: HTMLElement) {
|
||||||
|
let left = x;
|
||||||
|
let top = y;
|
||||||
|
|
||||||
|
if (anchor) {
|
||||||
|
if (props.anchorAt?.includes('right')) left += anchor.clientWidth;
|
||||||
|
if (props.anchorAt?.includes('bottom')) top += anchor.clientHeight;
|
||||||
|
if (props.anchorAt?.includes('center')) left += anchor.clientWidth / 2;
|
||||||
|
if (props.anchorAt?.includes('middle')) top += anchor.clientHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.popupAt?.includes('right')) left -= popup.clientWidth;
|
||||||
|
if (props.popupAt?.includes('bottom')) top -= popup.clientHeight;
|
||||||
|
if (props.popupAt?.includes('center')) left -= popup.clientWidth / 2;
|
||||||
|
if (props.popupAt?.includes('middle')) top -= popup.clientHeight / 2;
|
||||||
|
|
||||||
|
popup.style.setProperty('left', `${left}px`);
|
||||||
|
popup.style.setProperty('top', `${top}px`);
|
||||||
|
popup.style.setProperty('opacity', '1');
|
||||||
|
popup.style.setProperty('pointer-events', 'unset');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const onClose = (event: MouseEvent) => {
|
||||||
|
const isPopupClick = event.composedPath().some((element) => element === popup);
|
||||||
|
if (!isPopupClick) {
|
||||||
|
this.dismiss();
|
||||||
|
document.removeEventListener('click', onClose);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onClose);
|
||||||
|
}, 16);
|
||||||
|
},
|
||||||
|
showAtAnchor(anchor: HTMLElement) {
|
||||||
|
const { x, y } = anchor.getBoundingClientRect();
|
||||||
|
this.show(x, y, anchor);
|
||||||
|
},
|
||||||
|
|
||||||
|
isShowing() {
|
||||||
|
return popup.style.getPropertyValue('opacity') === '1';
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
popup.style.setProperty('opacity', '0');
|
||||||
|
popup.style.setProperty('pointer-events', 'none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ItemRendererProps = {
|
||||||
|
id?: string;
|
||||||
|
icon?: Element;
|
||||||
|
text: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
export const ItemRenderer = (props: ItemRendererProps) => {
|
||||||
|
const element = ElementFromHtml(itemHTML);
|
||||||
|
const iconContainer = element.querySelector<HTMLElement>('div.icon')!;
|
||||||
|
const textContainer = element.querySelector<HTMLElement>('div.text')!;
|
||||||
|
if (props.icon) iconContainer.appendChild(props.icon);
|
||||||
|
textContainer.append(props.text);
|
||||||
|
|
||||||
|
if (props.onClick) {
|
||||||
|
element.addEventListener('click', () => {
|
||||||
|
props.onClick?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (props.id) element.id = props.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
setIcon(icon: Element) {
|
||||||
|
iconContainer.replaceChildren(icon);
|
||||||
|
},
|
||||||
|
setText(text: string) {
|
||||||
|
textContainer.replaceChildren(text);
|
||||||
|
},
|
||||||
|
id: props.id
|
||||||
|
};
|
||||||
|
};
|
||||||
3
src/plugins/music-together/icons/connect.svg
Normal file
3
src/plugins/music-together/icons/connect.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||||
|
<path d="M480-640 280-440l56 56 104-103v407h80v-407l104 103 56-56-200-200ZM146-260q-32-49-49-105T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 59-17 115t-49 105l-58-58q22-37 33-78t11-84q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 43 11 84t33 78l-58 58Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 408 B |
4
src/plugins/music-together/icons/key.svg
Normal file
4
src/plugins/music-together/icons/key.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||||
|
<path
|
||||||
|
d="M280-400q-33 0-56.5-23.5T200-480q0-33 23.5-56.5T280-560q33 0 56.5 23.5T360-480q0 33-23.5 56.5T280-400Zm0 160q-100 0-170-70T40-480q0-100 70-170t170-70q67 0 121.5 33t86.5 87h352l120 120-180 180-80-60-80 60-85-60h-47q-32 54-86.5 87T280-240Zm0-80q56 0 98.5-34t56.5-86h125l58 41 82-61 71 55 75-75-40-40H435q-14-52-56.5-86T280-640q-66 0-113 47t-47 113q0 66 47 113t113 47Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 480 B |
3
src/plugins/music-together/icons/music-cast.svg
Normal file
3
src/plugins/music-together/icons/music-cast.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||||
|
<path d="M560-160q-66 0-113-47t-47-113q0-66 47-113t113-47q23 0 42.5 5.5T640-458v-342h240v120H720v360q0 66-47 113t-113 47ZM80-320q0-99 38-186.5T221-659q65-65 152.5-103T560-800v80q-82 0-155 31.5t-127.5 86q-54.5 54.5-86 127T160-320H80Zm160 0q0-66 25.5-124.5t69-102Q378-590 436-615t124-25v80q-100 0-170 70t-70 170h-80Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 416 B |
4
src/plugins/music-together/icons/off.svg
Normal file
4
src/plugins/music-together/icons/off.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||||
|
<path
|
||||||
|
d="M792-56 686-160H260q-92 0-156-64T40-380q0-77 47.5-137T210-594q3-8 6-15.5t6-16.5L56-792l56-56 736 736-56 56ZM260-240h346L284-562q-2 11-3 21t-1 21h-20q-58 0-99 41t-41 99q0 58 41 99t99 41Zm185-161Zm419 191-58-56q17-14 25.5-32.5T840-340q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-27 0-52 6.5T380-693l-58-58q35-24 74.5-36.5T480-800q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 39-15 72.5T864-210ZM593-479Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 529 B |
3
src/plugins/music-together/icons/tune.svg
Normal file
3
src/plugins/music-together/icons/tune.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||||
|
<path d="M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 298 B |
680
src/plugins/music-together/index.ts
Normal file
680
src/plugins/music-together/index.ts
Normal file
@ -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<never>['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<string | null>),
|
||||||
|
|
||||||
|
elements: {} as {
|
||||||
|
setting: HTMLElement;
|
||||||
|
icon: SVGElement;
|
||||||
|
spinner: HTMLElement;
|
||||||
|
},
|
||||||
|
popups: {} as {
|
||||||
|
host: ReturnType<typeof createHostPopup>;
|
||||||
|
guest: ReturnType<typeof createGuestPopup>;
|
||||||
|
setting: ReturnType<typeof createSettingPopup>;
|
||||||
|
},
|
||||||
|
stateInterval: null as number | null,
|
||||||
|
updateNext: false,
|
||||||
|
ignoreChange: false,
|
||||||
|
rollbackInjector: null as (() => void) | null,
|
||||||
|
|
||||||
|
me: null as Omit<Profile, 'id'> | null,
|
||||||
|
profiles: {} as Record<string, Profile>,
|
||||||
|
permission: 'playlist' as Permission,
|
||||||
|
|
||||||
|
/* events */
|
||||||
|
videoChangeListener(event: CustomEvent<VideoDataChanged>) {
|
||||||
|
if (event.detail.name === 'dataloaded' || this.updateNext) {
|
||||||
|
if (this.connection?.mode === 'host') {
|
||||||
|
const videoList: VideoData[] = this.queue?.flatItems.map((it: 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<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 = 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<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,
|
||||||
|
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<HTMLElement & AppAPI>('ytmusic-app');
|
||||||
|
|
||||||
|
/* setup */
|
||||||
|
document.querySelector('#right-content > ytmusic-settings-button')?.insertAdjacentHTML('beforebegin', settingHTML);
|
||||||
|
const setting = document.querySelector<HTMLElement>('#music-together-setting-button');
|
||||||
|
const icon = document.querySelector<SVGElement>('#music-together-setting-button > svg');
|
||||||
|
const spinner = document.querySelector<HTMLElement>('#music-together-setting-button > tp-yt-paper-spinner-lite');
|
||||||
|
if (!setting || !icon || !spinner) {
|
||||||
|
console.warn('Music Together: Cannot inject html');
|
||||||
|
console.log(setting, icon, spinner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elements = {
|
||||||
|
setting,
|
||||||
|
icon,
|
||||||
|
spinner
|
||||||
|
};
|
||||||
|
|
||||||
|
this.stateInterval = window.setInterval(() => {
|
||||||
|
if (this.connection?.mode !== 'host') return;
|
||||||
|
const index = this.queue?.selectedIndex ?? 0;
|
||||||
|
|
||||||
|
this.connection.broadcast('SYNC_PROGRESS', {
|
||||||
|
progress: this.playerApi?.getCurrentTime(),
|
||||||
|
state: this.playerApi?.getPlayerState(),
|
||||||
|
index
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
/* UI */
|
||||||
|
const hostPopup = createHostPopup({
|
||||||
|
onItemClick: (id) => {
|
||||||
|
if (id === 'music-together-close') {
|
||||||
|
this.onStop();
|
||||||
|
this.api?.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
40
src/plugins/music-together/queue/client.ts
Normal file
40
src/plugins/music-together/queue/client.ts
Normal file
@ -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(),
|
||||||
|
};
|
||||||
|
};
|
||||||
1
src/plugins/music-together/queue/index.ts
Normal file
1
src/plugins/music-together/queue/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './queue';
|
||||||
429
src/plugins/music-together/queue/queue.ts
Normal file
429
src/plugins/music-together/queue/queue.ts
Normal file
@ -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<HTMLElement & QueueAPI>('#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<HTMLElement>('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<HTMLImageElement>('.music-together-owner') ?? document.createElement('img');
|
||||||
|
profile.classList.add('music-together-owner');
|
||||||
|
profile.dataset.id = id;
|
||||||
|
profile.dataset.index = index.toString();
|
||||||
|
|
||||||
|
const name = item.querySelector<HTMLElement>('.music-together-name') ?? document.createElement('div');
|
||||||
|
name.classList.add('music-together-name');
|
||||||
|
name.textContent = data?.name ?? t('plugins.music-together.internal.unknown-user');
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
profile.dataset.thumbnail = data.thumbnail ?? '';
|
||||||
|
profile.dataset.name = data.name ?? '';
|
||||||
|
profile.dataset.handleId = data.handleId ?? '';
|
||||||
|
profile.dataset.id = data.id ?? '';
|
||||||
|
|
||||||
|
profile.src = data.thumbnail ?? '';
|
||||||
|
profile.title = data.name ?? '';
|
||||||
|
profile.alt = data.handleId ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.isConnected) item.append(profile);
|
||||||
|
if (!name.isConnected) item.append(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeQueueOwner() {
|
||||||
|
const allQueue = document.querySelectorAll('#queue');
|
||||||
|
|
||||||
|
allQueue.forEach((queue) => {
|
||||||
|
const list = Array.from(queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? []);
|
||||||
|
|
||||||
|
list.forEach((item) => {
|
||||||
|
const profile = item.querySelector<HTMLImageElement>('.music-together-owner');
|
||||||
|
const name = item.querySelector<HTMLElement>('.music-together-name');
|
||||||
|
profile?.remove();
|
||||||
|
name?.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* private */
|
||||||
|
private broadcast(event: ConnectionEventUnion) {
|
||||||
|
this.listeners.forEach((listener) => listener(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/plugins/music-together/queue/sha1hash.ts
Normal file
117
src/plugins/music-together/queue/sha1hash.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
48
src/plugins/music-together/queue/song.ts
Normal file
48
src/plugins/music-together/queue/song.ts
Normal file
@ -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<QueueRendererResponse | null> => {
|
||||||
|
const token = extractToken();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
'https://music.youtube.com/youtubei/v1/music/get_queue?key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30&prettyPrint=false',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
context: {
|
||||||
|
client: getClient(),
|
||||||
|
request: {
|
||||||
|
useSsl: true,
|
||||||
|
internalExperimentFlags: [],
|
||||||
|
consistencyTokenJars: [],
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
lockedSafetyMode: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
videoIds,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Origin: 'https://music.youtube.com',
|
||||||
|
Authorization: getAuthorizationHeader(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as QueueRendererResponse;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
15
src/plugins/music-together/queue/utils.ts
Normal file
15
src/plugins/music-together/queue/utils.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export const mapQueueItem = <T>(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);
|
||||||
|
|
||||||
160
src/plugins/music-together/style.css
Normal file
160
src/plugins/music-together/style.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
8
src/plugins/music-together/templates/item.html
Normal file
8
src/plugins/music-together/templates/item.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<div class="style-scope music-together-item">
|
||||||
|
<div class="icon style-scope ytmusic-menu-service-item-renderer">
|
||||||
|
<!-- icon -->
|
||||||
|
</div>
|
||||||
|
<div class="text style-scope ytmusic-menu-service-item-renderer">
|
||||||
|
<!-- text -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
5
src/plugins/music-together/templates/popup.html
Normal file
5
src/plugins/music-together/templates/popup.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<div class="music-together-popup">
|
||||||
|
<tp-yt-paper-listbox class="style-scope ytmusic-menu-popup-renderer music-together-popup-container">
|
||||||
|
|
||||||
|
</tp-yt-paper-listbox>
|
||||||
|
</div>
|
||||||
7
src/plugins/music-together/templates/setting.html
Normal file
7
src/plugins/music-together/templates/setting.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<div id="music-together-setting-button" class="music-together-button style-scope ytmusic-nav-bar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||||
|
<path d="M0-240v-63q0-43 44-70t116-27q13 0 25 .5t23 2.5q-14 21-21 44t-7 48v65H0Zm240 0v-65q0-32 17.5-58.5T307-410q32-20 76.5-30t96.5-10q53 0 97.5 10t76.5 30q32 20 49 46.5t17 58.5v65H240Zm540 0v-65q0-26-6.5-49T754-397q11-2 22.5-2.5t23.5-.5q72 0 116 26.5t44 70.5v63H780Zm-455-80h311q-10-20-55.5-35T480-370q-55 0-100.5 15T325-320ZM160-440q-33 0-56.5-23.5T80-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T160-440Zm640 0q-33 0-56.5-23.5T720-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T800-440Zm-320-40q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T600-600q0 50-34.5 85T480-480Zm0-80q17 0 28.5-11.5T520-600q0-17-11.5-28.5T480-640q-17 0-28.5 11.5T440-600q0 17 11.5 28.5T480-560Zm1 240Zm-1-280Z"/>
|
||||||
|
</svg>
|
||||||
|
<tp-yt-paper-spinner-lite id="music-together-host-spinner" hidden class="loading-indicator style-scope music-together-spinner"></tp-yt-paper-spinner-lite>
|
||||||
|
</div>
|
||||||
|
<div class="music-together-divider"></div>
|
||||||
23
src/plugins/music-together/templates/status.html
Normal file
23
src/plugins/music-together/templates/status.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<div class="music-together-status">
|
||||||
|
<div class="music-together-status-container">
|
||||||
|
<img class="music-together-profile big">
|
||||||
|
<div class="music-together-status-item">
|
||||||
|
<ytmd-trans key="plugins.music-together.name"></ytmd-trans>
|
||||||
|
<span id="music-together-status-label">
|
||||||
|
<ytmd-trans key="plugins.music-together.menu.status.disconnected"></ytmd-trans>
|
||||||
|
</span>
|
||||||
|
<span id="music-together-permission-label">
|
||||||
|
<ytmd-trans key="plugins.music-together.menu.permission.playlist" style="color: rgba(255, 255, 255, 0.75)"></ytmd-trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="music-together-divider horizontal" style="margin: 16px 0;"></div>
|
||||||
|
<div class="music-together-status-item">
|
||||||
|
<ytmd-trans key="plugins.music-together.menu.connected-users"></ytmd-trans>
|
||||||
|
</div>
|
||||||
|
<div class="music-together-user-container">
|
||||||
|
<span class="music-together-empty">
|
||||||
|
<ytmd-trans key="plugins.music-together.menu.empty-user"></ytmd-trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
54
src/plugins/music-together/types.ts
Normal file
54
src/plugins/music-together/types.ts
Normal file
@ -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`
|
||||||
|
};
|
||||||
|
};
|
||||||
43
src/plugins/music-together/ui/guest.ts
Normal file
43
src/plugins/music-together/ui/guest.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
60
src/plugins/music-together/ui/host.ts
Normal file
60
src/plugins/music-together/ui/host.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
49
src/plugins/music-together/ui/setting.ts
Normal file
49
src/plugins/music-together/ui/setting.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
82
src/plugins/music-together/ui/status.ts
Normal file
82
src/plugins/music-together/ui/status.ts
Normal file
@ -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<HTMLImageElement>('ytmusic-settings-button > tp-yt-paper-icon-button > tp-yt-iron-icon#icon img');
|
||||||
|
|
||||||
|
const profile = element.querySelector<HTMLImageElement>('.music-together-profile')!;
|
||||||
|
const statusLabel = element.querySelector<HTMLSpanElement>('#music-together-status-label')!;
|
||||||
|
const permissionLabel = element.querySelector<HTMLSpanElement>('#music-together-permission-label')!;
|
||||||
|
|
||||||
|
profile.src = icon?.src ?? '';
|
||||||
|
|
||||||
|
const setStatus = (status: 'disconnected' | 'host' | 'guest') => {
|
||||||
|
if (status === 'disconnected') {
|
||||||
|
statusLabel.textContent = t('plugins.music-together.menu.status.disconnected');
|
||||||
|
statusLabel.style.color = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'host') {
|
||||||
|
statusLabel.textContent = t('plugins.music-together.menu.status.host');
|
||||||
|
statusLabel.style.color = 'rgba(255, 0, 0, 1)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'guest') {
|
||||||
|
statusLabel.textContent = t('plugins.music-together.menu.status.guest');
|
||||||
|
statusLabel.style.color = 'rgba(255, 255, 255, 1)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPermission = (permission: Permission) => {
|
||||||
|
if (permission === 'host-only') {
|
||||||
|
permissionLabel.textContent = t('plugins.music-together.menu.permission.host-only');
|
||||||
|
permissionLabel.style.color = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === 'playlist') {
|
||||||
|
permissionLabel.textContent = t('plugins.music-together.menu.permission.playlist');
|
||||||
|
permissionLabel.style.color = 'rgba(255, 255, 255, 0.75)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === 'all') {
|
||||||
|
permissionLabel.textContent = t('plugins.music-together.menu.permission.all');
|
||||||
|
permissionLabel.style.color = 'rgba(255, 255, 255, 1)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProfile = (src: string) => {
|
||||||
|
profile.src = src;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUsers = (users: Profile[]) => {
|
||||||
|
const container = element.querySelector<HTMLDivElement>('.music-together-user-container')!;
|
||||||
|
const empty = element.querySelector<HTMLElement>('.music-together-empty')!;
|
||||||
|
for (const child of Array.from(container.children)) {
|
||||||
|
if (child !== empty) child.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.length === 0) empty.style.display = 'block';
|
||||||
|
else empty.style.display = 'none';
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.classList.add('music-together-profile');
|
||||||
|
img.src = user.thumbnail ?? '';
|
||||||
|
img.title = user.name;
|
||||||
|
img.alt = `${user.name} (${user.id})`;
|
||||||
|
|
||||||
|
container.append(img);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
setStatus,
|
||||||
|
setUsers,
|
||||||
|
setProfile,
|
||||||
|
setPermission,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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 => {
|
export const ElementFromHtml = (html: string): HTMLElement => {
|
||||||
const template = document.createElement('template');
|
const template = document.createElement('template');
|
||||||
html = html.trim(); // Never return a text node of whitespace as the result
|
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;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@ -257,4 +257,5 @@ export interface PlayerAPIEvents {
|
|||||||
videodatachange: {
|
videodatachange: {
|
||||||
value: VideoDataChangeValue;
|
value: VideoDataChangeValue;
|
||||||
} & ({ name: 'dataloaded' } | { name: 'dataupdated ' });
|
} & ({ name: 'dataloaded' } | { name: 'dataupdated ' });
|
||||||
|
onStateChange: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -262,7 +262,23 @@ export interface YoutubePlayer {
|
|||||||
showControls: () => void;
|
showControls: () => void;
|
||||||
hideControls: () => void;
|
hideControls: () => void;
|
||||||
cancelPlayback: () => void;
|
cancelPlayback: () => void;
|
||||||
getProgressState: <Return>() => 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;
|
isInline: () => boolean;
|
||||||
setInline: (isInline: boolean) => void;
|
setInline: (isInline: boolean) => void;
|
||||||
setLoopVideo: (value: boolean) => void;
|
setLoopVideo: (value: boolean) => void;
|
||||||
@ -320,6 +336,10 @@ export interface YoutubePlayer {
|
|||||||
getVolume: () => number;
|
getVolume: () => number;
|
||||||
seekTo: (seconds: number) => void;
|
seekTo: (seconds: number) => void;
|
||||||
getPlayerMode: <Return>() => Return;
|
getPlayerMode: <Return>() => Return;
|
||||||
|
/**
|
||||||
|
* 1: playing
|
||||||
|
* 2: paused
|
||||||
|
*/
|
||||||
getPlayerState: () => number;
|
getPlayerState: () => number;
|
||||||
getAvailablePlaybackRates: () => number[];
|
getAvailablePlaybackRates: () => number[];
|
||||||
getPlaybackQuality: () => string;
|
getPlaybackQuality: () => string;
|
||||||
|
|||||||
5
src/youtube-music.d.ts
vendored
5
src/youtube-music.d.ts
vendored
@ -15,6 +15,11 @@ declare module '*.svg?inline' {
|
|||||||
|
|
||||||
export default base64;
|
export default base64;
|
||||||
}
|
}
|
||||||
|
declare module '*.svg?raw' {
|
||||||
|
const html: string;
|
||||||
|
|
||||||
|
export default html;
|
||||||
|
}
|
||||||
declare module '*.png' {
|
declare module '*.png' {
|
||||||
const element: HTMLImageElement;
|
const element: HTMLImageElement;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user