diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 57b55495..17ef6588 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -472,6 +472,7 @@ "disconnected": "Music Together disconnected", "host-failed": "Failed to host Music Together", "id-copied": "Host ID copied to clipboard", + "id-copy-failed": "Failed to copy Host ID to clipboard", "join-failed": "Failed to join Music Together", "joined": "Joined Music Together", "permission-changed": "Music Together permission changed to \"{{permission}}\"", diff --git a/src/plugins/music-together/index.ts b/src/plugins/music-together/index.ts index 1024a0a6..a9549ef1 100644 --- a/src/plugins/music-together/index.ts +++ b/src/plugins/music-together/index.ts @@ -1,5 +1,7 @@ import prompt from 'custom-electron-prompt'; +import { DataConnection } from 'peerjs'; + import { t } from '@/i18n'; import { createPlugin } from '@/utils'; import promptOptions from '@/providers/prompt-options'; @@ -17,7 +19,6 @@ 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: { @@ -34,7 +35,44 @@ type RawAccountData = { }; }; -export default createPlugin({ +export default createPlugin< + unknown, + unknown, + { + connection?: Connection; + ipc?: RendererContext['ipc']; + api: HTMLElement & AppAPI | null; + queue?: Queue; + playerApi?: YoutubePlayer; + showPrompt: (title: string, label: string) => Promise; + popups: { + host: ReturnType; + guest: ReturnType; + setting: ReturnType; + }; + elements: { + setting: HTMLElement; + icon: SVGElement; + spinner: HTMLElement; + }; + stateInterval?: number; + updateNext: boolean; + ignoreChange: boolean; + rollbackInjector?: (() => void); + me?: Omit; + profiles: Record; + permission: Permission; + videoChangeListener: (event: CustomEvent) => void; + videoStateChangeListener: () => void; + onHost: () => Promise; + onJoin: () => Promise; + onStop: () => void; + putProfile: (id: string, profile?: Profile) => void; + showSpinner: () => void; + hideSpinner: () => void; + initMyProfile: () => void; + } +>({ name: () => t('plugins.music-together.name'), description: () => t('plugins.music-together.description'), restartNeeded: false, @@ -43,50 +81,38 @@ export default createPlugin({ enabled: false }, stylesheets: [style], - backend: { - async start({ ipc }) { - ipc.handle('music-together:prompt', async (title: string, label: string) => prompt({ - title, - label, - type: 'input', - ...promptOptions() - })); - } + backend({ ipc }) { + ipc.handle('music-together:prompt', async (title: string, label: string) => prompt({ + title, + label, + type: 'input', + ...promptOptions() + })); }, renderer: { - connection: null as Connection | null, - ipc: null as RendererContext['ipc'] | null, - - api: null as (HTMLElement & AppAPI) | null, - queue: null as Queue | null, - playerApi: null as YoutubePlayer | null, - showPrompt: (async () => null) as ((title: string, label: string) => Promise), - - elements: {} as { - setting: HTMLElement; - icon: SVGElement; - spinner: HTMLElement; - }, + updateNext: false, + ignoreChange: false, + permission: 'playlist', popups: {} as { host: ReturnType; guest: ReturnType; setting: ReturnType; }, - stateInterval: null as number | null, - updateNext: false, - ignoreChange: false, - rollbackInjector: null as (() => void) | null, - - me: null as Omit | null, - profiles: {} as Record, - permission: 'playlist' as Permission, + elements: {} as { + setting: HTMLElement; + icon: SVGElement; + spinner: HTMLElement; + }, + profiles: {}, + showPrompt: () => Promise.resolve(''), + api: null, /* events */ videoChangeListener(event: CustomEvent) { if (event.detail.name === 'dataloaded' || this.updateNext) { if (this.connection?.mode === 'host') { - const videoList: VideoData[] = this.queue?.flatItems.map((it: any) => ({ - videoId: it.videoId, + const videoList: VideoData[] = this.queue?.flatItems.map((it) => ({ + videoId: it!.videoId, ownerId: this.connection!.id } satisfies VideoData)) ?? []; @@ -123,8 +149,8 @@ export default createPlugin({ if (!wait) return false; if (!this.me) this.me = getDefaultProfile(this.connection.id); - const rawItems = this.queue?.flatItems?.map((it: any) => ({ - videoId: it.videoId, + const rawItems = this.queue?.flatItems?.map((it) => ({ + videoId: it!.videoId, ownerId: this.connection!.id } satisfies VideoData)) ?? []; this.queue?.setOwner({ @@ -170,7 +196,7 @@ export default createPlugin({ case 'REMOVE_SONG': { if (conn && this.permission === 'host-only') return; - await this.queue?.removeVideo(event.payload.index); + this.queue?.removeVideo(event.payload.index); await this.connection?.broadcast('REMOVE_SONG', event.payload); break; } @@ -295,11 +321,11 @@ export default createPlugin({ break; } case 'REMOVE_SONG': { - await this.queue?.removeVideo(event.payload.index); + this.queue?.removeVideo(event.payload.index); break; } case 'MOVE_SONG': { - await this.queue?.moveItem(event.payload.fromIndex, event.payload.toIndex); + this.queue?.moveItem(event.payload.fromIndex, event.payload.toIndex); break; } case 'IDENTIFY': { @@ -461,7 +487,7 @@ export default createPlugin({ this.queue?.removeQueueOwner(); if (this.rollbackInjector) { this.rollbackInjector(); - this.rollbackInjector = null; + this.rollbackInjector = undefined; } this.profiles = {}; @@ -530,7 +556,7 @@ export default createPlugin({ start({ ipc }) { this.ipc = ipc; - this.showPrompt = async (title: string, label: string) => ipc.invoke('music-together:prompt', title, label); + this.showPrompt = async (title: string, label: string) => ipc.invoke('music-together:prompt', title, label) as Promise; this.api = document.querySelector('ytmusic-app'); /* setup */ @@ -571,10 +597,15 @@ export default createPlugin({ } if (id === 'music-together-copy-id') { - navigator.clipboard.writeText(this.connection?.id ?? ''); - - this.api?.openToast(t('plugins.music-together.toast.id-copied')); - hostPopup.dismiss(); + navigator.clipboard.writeText(this.connection?.id ?? '') + .then(() => { + this.api?.openToast(t('plugins.music-together.toast.id-copied')); + hostPopup.dismiss(); + }) + .catch(() => { + this.api?.openToast(t('plugins.music-together.toast.id-copy-failed')); + hostPopup.dismiss(); + }); } if (id === 'music-together-permission') { @@ -614,9 +645,14 @@ export default createPlugin({ this.hideSpinner(); if (result) { - navigator.clipboard.writeText(this.connection?.id ?? ''); - this.api?.openToast(t('plugins.music-together.toast.id-copied')); - hostPopup.showAtAnchor(setting); + navigator.clipboard.writeText(this.connection?.id ?? '') + .then(() => { + this.api?.openToast(t('plugins.music-together.toast.id-copied')); + hostPopup.showAtAnchor(setting); + }).catch(() => { + this.api?.openToast(t('plugins.music-together.toast.id-copy-failed')); + hostPopup.showAtAnchor(setting); + }); } else { this.api?.openToast(t('plugins.music-together.toast.host-failed')); } @@ -642,7 +678,7 @@ export default createPlugin({ guest: guestPopup, setting: settingPopup }; - setting.addEventListener('click', async () => { + setting.addEventListener('click', () => { let popup = settingPopup; if (this.connection?.mode === 'host') popup = hostPopup; if (this.connection?.mode === 'guest') popup = guestPopup; diff --git a/src/plugins/music-together/queue/queue.ts b/src/plugins/music-together/queue/queue.ts index b33104d9..f2367e85 100644 --- a/src/plugins/music-together/queue/queue.ts +++ b/src/plugins/music-together/queue/queue.ts @@ -1,12 +1,51 @@ 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'; +import type { Profile, QueueAPI, VideoData } from '../types'; +import type { QueueItem } from '@/types/datahost-get-state'; + const getHeaderPayload = (() => { - let payload: unknown = null; + let payload: { + items?: QueueItem[] | undefined; + title: { + runs: { + text: string; + }[]; + }; + subtitle: { + runs: { + text: string; + }[]; + }; + buttons: { + chipCloudChipRenderer: { + style: { + styleType: string; + }; + text: { + runs: { + text: string; + }[]; + }; + navigationEndpoint: { + saveQueueToPlaylistCommand: unknown; + }; + icon: { + iconType: string; + }; + accessibilityData: { + accessibilityData: { + label: string; + }; + }; + isSelected: boolean; + uniqueId: string; + }; + }[]; + } | null = null; return () => { if (!payload) { @@ -58,7 +97,7 @@ const getHeaderPayload = (() => { } return payload; - } + }; })(); export type QueueOptions = { @@ -70,11 +109,11 @@ export type QueueOptions = { export type QueueEventListener = (event: ConnectionEventUnion) => void; export class Queue { - private queue: (HTMLElement & QueueAPI) | null = null; - private originalDispatch: ((obj: { + private queue: (HTMLElement & QueueAPI); + private originalDispatch?: (obj: { type: string; - payload?: unknown; - }) => void) | null = null; + payload?: { items?: QueueItem[] | undefined; }; + }) => void; private internalDispatch = false; private ignoreFlag = false; private listeners: QueueEventListener[] = []; @@ -83,7 +122,7 @@ export class Queue { constructor(options: QueueOptions) { this.getProfile = options.getProfile; - this.queue = options.queue ?? document.querySelector('#queue'); + this.queue = options.queue ?? document.querySelector('#queue')!; this.owner = options.owner ?? null; this._videoList = options.videoList ?? []; } @@ -96,7 +135,7 @@ export class Queue { } get selectedIndex() { - return mapQueueItem((it) => it?.selected, this.queue?.store.getState().queue.items).findIndex(Boolean) ?? 0; + return mapQueueItem((it) => it?.selected, this.queue.store.getState().queue.items).findIndex(Boolean) ?? 0; } get rawItems() { @@ -146,7 +185,7 @@ export class Queue { return true; } - async removeVideo(index: number) { + removeVideo(index: number) { this.internalDispatch = true; this._videoList.splice(index, 1); this.queue?.dispatch({ @@ -233,10 +272,10 @@ export class Queue { if (event.type === 'ADD_ITEMS') { if (this.ignoreFlag) { this.ignoreFlag = false; - const videoList = mapQueueItem((it: any) => ({ - videoId: it.videoId, + const videoList = mapQueueItem((it) => ({ + videoId: it!.videoId, ownerId: this.owner!.id - } satisfies VideoData), (event.payload as any).items); + } satisfies VideoData), event.payload!.items!); const index = this._videoList.length + videoList.length - 1; if (videoList.length > 0) { @@ -255,15 +294,17 @@ export class Queue { ] }); } - } else if ((event.payload as any).items.length === 1) { + } else if ((event.payload as { + items: unknown[]; + }).items.length === 1) { this.broadcast({ // add playlist type: 'ADD_SONGS', payload: { // index: (event.payload as any).index, - videoList: mapQueueItem((it: any) => ({ - videoId: it.videoId, + videoList: mapQueueItem((it) => ({ + videoId: it!.videoId, ownerId: this.owner!.id - } satisfies VideoData), (event.payload as any).items) + } satisfies VideoData), event.payload!.items!) } }); } @@ -275,8 +316,12 @@ export class Queue { this.broadcast({ type: 'MOVE_SONG', payload: { - fromIndex: (event.payload as any).fromIndex, - toIndex: (event.payload as any).toIndex + fromIndex: (event.payload as { + fromIndex: number; + }).fromIndex, + toIndex: (event.payload as { + toIndex: number; + }).toIndex } }); return; @@ -306,7 +351,7 @@ export class Queue { event.payload = undefined; } if (event.type === 'SET_PLAYER_UI_STATE') { - if (event.payload === 'INACTIVE' && this.videoList.length > 0) { + if (event.payload as string === 'INACTIVE' && this.videoList.length > 0) { return; } } @@ -321,12 +366,12 @@ export class Queue { dispatch: this.originalDispatch } }; - this.originalDispatch!.call(fakeContext, event); + this.originalDispatch?.call(fakeContext, event); }; } /* sync */ - async initQueue() { + initQueue() { if (!this.queue) return; this.internalDispatch = true; @@ -369,13 +414,13 @@ export class Queue { return true; } - async syncQueueOwner() { + syncQueueOwner() { const allQueue = document.querySelectorAll('#queue'); allQueue.forEach((queue) => { const list = Array.from(queue?.querySelectorAll('ytmusic-player-queue-item') ?? []); - list.forEach((item, index) => { + list.forEach((item, index: number | undefined) => { if (typeof index !== 'number') return; const id = this._videoList[index]?.ownerId; diff --git a/src/plugins/music-together/queue/sha1hash.ts b/src/plugins/music-together/queue/sha1hash.ts index 9806b18b..9e17afe3 100644 --- a/src/plugins/music-together/queue/sha1hash.ts +++ b/src/plugins/music-together/queue/sha1hash.ts @@ -12,14 +12,14 @@ export function SHA1Hash(): { } function processBlock(block: number[]): void { - let words: number[] = []; + const 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]; + 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; + const temp = words[i - 3] ^ words[i - 8] ^ words[i - 14] ^ words[i - 16]; + words[i] = ((temp << 1) | (temp >>> 31)) & 4294967295; } let a = hash[0], @@ -30,22 +30,22 @@ export function SHA1Hash(): { for (let i = 0; i < 80; i++) { let f, k; if (i < 20) { - f = d ^ b & (c ^ d); + 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); + 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; + const temp = (((a << 5) | (a >>> 27)) & 4294967295) + f + e + k + words[i] & 4294967295; e = d; d = c; - c = (b << 30 | b >>> 2) & 4294967295; + c = ((b << 30) | (b >>> 2)) & 4294967295; b = a; a = temp; } @@ -58,8 +58,9 @@ export function SHA1Hash(): { function update(message: string | number[], length?: number): void { if ('string' === typeof message) { + // HACK: to decode UTF-8 message = unescape(encodeURIComponent(message)); - let bytes: number[] = []; + const bytes: number[] = []; for (let i = 0, len = message.length; i < len; ++i) bytes.push(message.charCodeAt(i)); message = bytes; @@ -67,48 +68,59 @@ export function SHA1Hash(): { length || (length = message.length); let i = 0; if (0 == currentLength) - for (; i + 64 < length;) - processBlock(message.slice(i, i + 64)), - i += 64, + 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; - 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) + const result: number[] = []; + let bits = 8 * totalLength; + if (currentLength < 56) { update(padding, 56 - currentLength); - else + } else { update(padding, 64 - (currentLength - 56)); - for (let i = 63; i >= 56; i--) - buffer[i] = bits & 255, - bits >>>= 8; + } + 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); + 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) + const buffer: number[] = []; + const padding: number[] = [128]; + let totalLength: number; + let 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 = ''; + const hash = finalize(); + let hex = ''; for (let i = 0; i < hash.length; i++) hex += '0123456789ABCDEF'.charAt(Math.floor(hash[i] / 16)) + '0123456789ABCDEF'.charAt(hash[i] % 16); return hex; diff --git a/src/plugins/music-together/queue/utils.ts b/src/plugins/music-together/queue/utils.ts index be807e49..f76e58a7 100644 --- a/src/plugins/music-together/queue/utils.ts +++ b/src/plugins/music-together/queue/utils.ts @@ -1,15 +1,21 @@ -export const mapQueueItem = (map: (item: any | null) => T, array: any[]): T[] => array +import { + ItemPlaylistPanelVideoRenderer, + PlaylistPanelVideoWrapperRenderer, + QueueItem +} from '@/types/datahost-get-state'; + +export const mapQueueItem = (map: (item?: ItemPlaylistPanelVideoRenderer) => T, array: QueueItem[]): T[] => array .map((item) => { if ('playlistPanelVideoWrapperRenderer' in item) { - const keys = Object.keys(item.playlistPanelVideoWrapperRenderer.primaryRenderer); - return item.playlistPanelVideoWrapperRenderer.primaryRenderer[keys[0]]; + const keys = Object.keys(item.playlistPanelVideoWrapperRenderer!.primaryRenderer) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[]; + return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]]; } if ('playlistPanelVideoRenderer' in item) { return item.playlistPanelVideoRenderer; } console.error('Music Together: Unknown item', item); - return null; + return undefined; }) .map(map); diff --git a/src/plugins/music-together/templates/status.html b/src/plugins/music-together/templates/status.html index 3d85e073..c273930b 100644 --- a/src/plugins/music-together/templates/status.html +++ b/src/plugins/music-together/templates/status.html @@ -1,14 +1,14 @@
- + Profile Image
- + - +
diff --git a/src/plugins/music-together/types.ts b/src/plugins/music-together/types.ts index 157a85f2..cec1c822 100644 --- a/src/plugins/music-together/types.ts +++ b/src/plugins/music-together/types.ts @@ -1,15 +1,19 @@ import { YoutubePlayer } from '@/types/youtube-player'; -type StoreState = any; +import { GetState, QueueItem } from '@/types/datahost-get-state'; + +type StoreState = GetState; type Store = { dispatch: (obj: { type: string; - payload?: unknown; + payload?: { + items?: QueueItem[]; + }; }) => void; getState: () => StoreState; replaceReducer: (param1: unknown) => unknown; subscribe: (callback: () => void) => unknown; -}; +} export type QueueAPI = { dispatch(obj: { type: string; @@ -28,8 +32,6 @@ export type AppAPI = { // TODO: Add more }; - - export type Profile = { id: string; handleId: string; diff --git a/src/plugins/music-together/ui/status.ts b/src/plugins/music-together/ui/status.ts index 9119ed8d..ff301293 100644 --- a/src/plugins/music-together/ui/status.ts +++ b/src/plugins/music-together/ui/status.ts @@ -1,6 +1,8 @@ import { ElementFromHtml } from '@/plugins/utils/renderer'; -import statusHTML from '../templates/status.html?raw'; import { t } from '@/i18n'; + +import statusHTML from '../templates/status.html?raw'; + import type { Permission, Profile } from '../types'; export const createStatus = () => { @@ -9,7 +11,7 @@ export const createStatus = () => { const profile = element.querySelector('.music-together-profile')!; const statusLabel = element.querySelector('#music-together-status-label')!; - const permissionLabel = element.querySelector('#music-together-permission-label')!; + const permissionLabel = element.querySelector('#music-together-permission-label')!; profile.src = icon?.src ?? ''; diff --git a/src/providers/song-info-front.ts b/src/providers/song-info-front.ts index 057531e5..bfde8c44 100644 --- a/src/providers/song-info-front.ts +++ b/src/providers/song-info-front.ts @@ -127,7 +127,7 @@ export default (api: YoutubePlayer) => { const waitingEvent = new Set(); // Name = "dataloaded" and abit later "dataupdated" - api.addEventListener('videodatachange', (name: string, videoData) => { + api.addEventListener('videodatachange', (name, videoData) => { videoEventDispatcher(name, videoData); if (name === 'dataupdated' && waitingEvent.has(videoData.videoId)) { diff --git a/src/types/player-api-events.ts b/src/types/player-api-events.ts index 9575818b..fe52a808 100644 --- a/src/types/player-api-events.ts +++ b/src/types/player-api-events.ts @@ -256,6 +256,7 @@ export type VideoDataChangeValue = Record & { export interface PlayerAPIEvents { videodatachange: { value: VideoDataChangeValue; - } & ({ name: 'dataloaded' } | { name: 'dataupdated ' }); + name: 'dataloaded' | 'dataupdated'; + }; onStateChange: number; } diff --git a/src/types/youtube-player.ts b/src/types/youtube-player.ts index 44a29225..96c448cf 100644 --- a/src/types/youtube-player.ts +++ b/src/types/youtube-player.ts @@ -357,8 +357,8 @@ export interface YoutubePlayer { type: K, listener: ( this: Document, - name: PlayerAPIEvents[K]['name'], - data: PlayerAPIEvents[K]['value'], + name: K extends 'videodatachange' ? PlayerAPIEvents[K]['name'] : never, + data: K extends 'videodatachange' ? PlayerAPIEvents[K]['value'] : never, ) => void, options?: boolean | AddEventListenerOptions | undefined, ) => void; @@ -366,8 +366,8 @@ export interface YoutubePlayer { type: K, listener: ( this: Document, - name: PlayerAPIEvents[K]['name'], - data: PlayerAPIEvents[K]['value'], + name: K extends 'videodatachange' ? PlayerAPIEvents[K]['name'] : never, + data: K extends 'videodatachange' ? PlayerAPIEvents[K]['value'] : never, ) => void, options?: boolean | EventListenerOptions | undefined, ) => void;