import prompt from 'custom-electron-prompt'; import { t } from '@/i18n'; import { createPlugin } from '@/utils'; import promptOptions from '@/providers/prompt-options'; import { waitForElement } from '@/utils/wait-for-element'; import { getDefaultProfile, type Permission, type Profile, type VideoData, } from './types'; import { Queue } from './queue'; import { Connection, type ConnectionEventUnion } from './connection'; import { createHostPopup } from './ui/host'; import { createGuestPopup } from './ui/guest'; import { createSettingPopup } from './ui/setting'; import settingHTML from './templates/setting.html?raw'; import style from './style.css?inline'; import type { DataConnection } from 'peerjs'; import type { MusicPlayer } from '@/types/music-player'; import type { RendererContext } from '@/types/contexts'; import type { VideoDataChanged } from '@/types/video-data-changed'; import type { AppElement } from '@/types/queue'; type RawAccountData = { accountName: { runs: { text: string }[]; }; accountPhoto: { thumbnails: { url: string; width: number; height: number }[]; }; settingsEndpoint: unknown; manageAccountTitle: unknown; trackingParams: string; channelHandle: { runs: { text: string }[]; }; }; export default createPlugin< unknown, unknown, { connection?: Connection; ipc?: RendererContext['ipc']; api: AppElement | null; queue?: Queue; playerApi?: MusicPlayer; 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, ) => Promise; videoStateChangeListener: () => Promise; 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, addedVersion: '3.2.X', config: { enabled: false, }, stylesheets: [style], backend({ ipc }) { ipc.handle('music-together:prompt', async (title: string, label: string) => prompt({ title, label, type: 'input', ...promptOptions(), }), ); }, renderer: { updateNext: false, ignoreChange: false, permission: 'playlist', popups: {} as { host: ReturnType; guest: ReturnType; setting: ReturnType; }, elements: {} as { setting: HTMLElement; icon: SVGElement; spinner: HTMLElement; }, profiles: {}, showPrompt: () => Promise.resolve(''), api: null, /* events */ async videoChangeListener(event: CustomEvent) { if (event.detail.name === 'dataloaded' || this.updateNext) { if (this.connection?.mode === 'host') { const videoList: VideoData[] = this.queue?.flatItems.map( (it, index) => ({ videoId: it!.videoId, ownerId: this.queue?.videoList[index]?.ownerId ?? this.connection!.id, }) satisfies VideoData, ) ?? []; this.queue?.setVideoList(videoList, false); this.queue?.syncQueueOwner(); await this.connection.broadcast('SYNC_QUEUE', { videoList, }); this.updateNext = event.detail.name === 'dataloaded'; } } }, async 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; await 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); this.profiles = {}; this.putProfile(this.connection.id, { id: this.connection.id, ...this.me, }); this.queue?.setOwner({ id: this.connection.id, ...this.me, }); const rawItems = this.queue?.flatItems?.map( (it) => ({ videoId: it!.videoId, ownerId: this.connection!.id, }) satisfies VideoData, ) ?? []; this.queue?.setVideoList(rawItems, false); this.queue?.syncQueueOwner(); this.queue?.initQueue(); this.queue?.injection(); this.connection.onConnections((connection) => { if (!connection) { this.api?.toastService?.show( t('plugins.music-together.toast.disconnected'), ); this.onStop(); return; } if (!connection.open) { this.api?.toastService?.show( t('plugins.music-together.toast.user-disconnected', { name: this.profiles[connection.peer]?.name, }), ); this.putProfile(connection.peer, undefined); } }); const listener = async ( event: ConnectionEventUnion, conn?: DataConnection | null, ) => { this.ignoreChange = true; switch (event.type) { case 'ADD_SONGS': { if (conn && this.permission === 'host-only') { await this.connection?.broadcast('SYNC_QUEUE', { videoList: this.queue?.videoList ?? [], }); return; } const videoList: VideoData[] = event.payload.videoList.map( (it) => ({ ...it, ownerId: it.ownerId ?? conn?.peer ?? this.connection!.id, }), ); await this.queue?.addVideos(videoList, event.payload.index); await this.connection?.broadcast('ADD_SONGS', { ...event.payload, videoList, }); break; } case 'REMOVE_SONG': { if (conn && this.permission === 'host-only') { await this.connection?.broadcast('SYNC_QUEUE', { videoList: this.queue?.videoList ?? [], }); return; } this.queue?.removeVideo(event.payload.index); await this.connection?.broadcast('REMOVE_SONG', event.payload); break; } case 'MOVE_SONG': { if (conn && this.permission === 'host-only') { await this.connection?.broadcast('SYNC_QUEUE', { videoList: this.queue?.videoList ?? [], }); break; } this.queue?.moveItem( event.payload.fromIndex, event.payload.toIndex, ); await this.connection?.broadcast('MOVE_SONG', event.payload); break; } case 'IDENTIFY': { if (!event.payload || !conn) { console.warn( 'Music Together [Host]: Received "IDENTIFY" event without payload or connection', ); break; } this.api?.toastService?.show( t('plugins.music-together.toast.user-connected', { name: event.payload.profile.name, }), ); this.putProfile(conn.peer, event.payload.profile); break; } case 'SYNC_PROFILE': { await this.connection?.broadcast('SYNC_PROFILE', { profiles: this.profiles, }); break; } case 'PERMISSION': { await this.connection?.broadcast('PERMISSION', this.permission); this.popups.guest.setPermission(this.permission); this.popups.host.setPermission(this.permission); this.popups.setting.setPermission(this.permission); break; } case 'SYNC_QUEUE': { await this.connection?.broadcast('SYNC_QUEUE', { videoList: this.queue?.videoList ?? [], }); break; } case 'SYNC_PROGRESS': { let permissionLevel = 0; if (this.permission === 'all') permissionLevel = 2; if (this.permission === 'playlist') permissionLevel = 1; if (this.permission === 'host-only') permissionLevel = 0; if (!conn) permissionLevel = 3; if (permissionLevel >= 2) { if (typeof event.payload?.progress === 'number') { const currentTime = this.playerApi?.getCurrentTime() ?? 0; if (Math.abs(event.payload.progress - currentTime) > 3) this.playerApi?.seekTo(event.payload.progress); } if (this.playerApi?.getPlayerState() !== event.payload?.state) { if (event.payload?.state === 2) this.playerApi?.pauseVideo(); if (event.payload?.state === 1) this.playerApi?.playVideo(); } } if (permissionLevel >= 1) { if (typeof event.payload?.index === 'number') { const nowIndex = this.queue?.selectedIndex ?? 0; if (nowIndex !== event.payload.index) { this.queue?.setIndex(event.payload.index); } } } break; } case 'CONNECTION_CLOSED': { this.queue?.off(listener); break; } default: { console.warn('Music Together [Host]: Unknown Event', event); break; } } if (event.after) { const now = event.after.shift(); if (now) { now.after = event.after; await listener(now, conn); } } }; this.connection.on(listener); this.queue?.on(listener); setTimeout(() => { this.ignoreChange = false; }, 16); // wait 1 frame return true; }, async onJoin() { this.connection = new Connection(); const wait = await this.connection.waitForReady().catch(() => null); if (!wait) return false; this.profiles = {}; const id = await this.showPrompt( t('plugins.music-together.name'), t('plugins.music-together.dialog.enter-host'), ); if (typeof id !== 'string') return false; const connection = await this.connection.connect(id).catch(() => false); if (!connection) return false; this.connection.onConnections((connection) => { if (!connection?.open) { this.api?.toastService?.show( t('plugins.music-together.toast.disconnected'), ); this.onStop(); } }); let resolveIgnore: number | null = null; const queueListener = async (event: ConnectionEventUnion) => { this.ignoreChange = true; switch (event.type) { case 'ADD_SONGS': { await this.connection?.broadcast('ADD_SONGS', { ...event.payload, videoList: event.payload.videoList.map((it) => ({ ...it, ownerId: it.ownerId ?? this.connection!.id, })), }); break; } case 'REMOVE_SONG': { await this.connection?.broadcast('REMOVE_SONG', event.payload); break; } case 'MOVE_SONG': { await this.connection?.broadcast('MOVE_SONG', event.payload); 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 }; const listener = async (event: ConnectionEventUnion) => { this.ignoreChange = true; switch (event.type) { case 'ADD_SONGS': { const videoList: VideoData[] = event.payload.videoList.map( (it) => ({ ...it, ownerId: it.ownerId ?? this.connection!.id, }), ); await this.queue?.addVideos(videoList, event.payload.index); break; } case 'REMOVE_SONG': { this.queue?.removeVideo(event.payload.index); break; } case 'MOVE_SONG': { this.queue?.moveItem( event.payload.fromIndex, event.payload.toIndex, ); break; } case 'IDENTIFY': { console.warn( 'Music Together [Guest]: Received "IDENTIFY" event from guest', ); break; } case 'SYNC_QUEUE': { if (Array.isArray(event.payload?.videoList)) { await this.queue?.setVideoList(event.payload.videoList); } break; } case 'SYNC_PROFILE': { if (!event.payload) { console.warn( 'Music Together [Guest]: Received "SYNC_PROFILE" event without payload', ); break; } Object.entries(event.payload.profiles).forEach(([id, profile]) => { this.putProfile(id, profile); }); break; } case 'SYNC_PROGRESS': { if (typeof event.payload?.progress === 'number') { const currentTime = this.playerApi?.getCurrentTime() ?? 0; if (Math.abs(event.payload.progress - currentTime) > 3) this.playerApi?.seekTo(event.payload.progress); } if (this.playerApi?.getPlayerState() !== event.payload?.state) { if (event.payload?.state === 2) this.playerApi?.pauseVideo(); if (event.payload?.state === 1) this.playerApi?.playVideo(); } if (typeof event.payload?.index === 'number') { const nowIndex = this.queue?.selectedIndex ?? 0; if (nowIndex !== event.payload.index) { this.queue?.setIndex(event.payload.index); } } break; } case 'PERMISSION': { if (!event.payload) { console.warn( 'Music Together [Guest]: Received "PERMISSION" event without payload', ); break; } this.permission = event.payload; this.popups.guest.setPermission(this.permission); this.popups.host.setPermission(this.permission); this.popups.setting.setPermission(this.permission); const permissionLabel = t( `plugins.music-together.menu.permission.${this.permission}`, ); this.api?.toastService?.show( t('plugins.music-together.toast.permission-changed', { permission: permissionLabel, }), ); break; } case 'CONNECTION_CLOSED': { this.queue?.off(queueListener); 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(queueListener); 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()); }; await this.connection.broadcast('IDENTIFY', { profile: { id: this.connection.id, handleId: this.me.handleId, name: this.me.name, thumbnail: this.me.thumbnail, }, }); await this.connection.broadcast('SYNC_PROFILE', undefined); await this.connection.broadcast('PERMISSION', undefined); this.queue?.clear(); this.queue?.syncQueueOwner(); this.queue?.initQueue(); await this.connection.broadcast('SYNC_QUEUE', undefined); return true; }, onStop() { if (this.connection?.mode !== 'disconnected') { this.connection?.disconnect(); } this.queue?.rollbackInjection(); this.queue?.removeQueueOwner(); if (this.rollbackInjector) { this.rollbackInjector(); this.rollbackInjector = undefined; } this.profiles = {}; this.popups.host.setUsers(Object.values(this.profiles)); this.popups.guest.setUsers(Object.values(this.profiles)); this.popups.host.dismiss(); this.popups.guest.dismiss(); this.popups.setting.dismiss(); }, /* methods */ putProfile(id: string, profile?: Profile) { if (profile === undefined) { delete this.profiles[id]; } else { this.profiles[id] = profile; } this.popups.host.setUsers(Object.values(this.profiles)); this.popups.guest.setUsers(Object.values(this.profiles)); }, showSpinner() { this.elements.icon.style.setProperty('display', 'none'); this.elements.spinner.removeAttribute('hidden'); this.elements.spinner.setAttribute('active', ''); }, hideSpinner() { this.elements.icon.style.removeProperty('display'); this.elements.spinner.removeAttribute('active'); this.elements.spinner.setAttribute('hidden', ''); }, async initMyProfile() { const accountButton = await waitForElement( '#right-content > ytmusic-settings-button *:where(tp-yt-paper-icon-button,yt-icon-button,.ytmusic-settings-button)', { maxRetry: 10000, }, ); accountButton?.click(); setTimeout(async () => { const renderer = await waitForElement( 'ytd-active-account-header-renderer', { maxRetry: 10000, }, ); if (!accountButton || !renderer) { console.warn('Music Together: Cannot find account'); this.me = getDefaultProfile(this.connection?.id ?? ''); return; } const accountData = renderer.data as RawAccountData; this.me = { handleId: accountData.channelHandle.runs[0].text ?? accountData.accountName.runs[0].text, name: accountData.accountName.runs[0].text, thumbnail: accountData.accountPhoto.thumbnails[0].url, }; if (this.me.thumbnail) { this.popups.host.setProfile(this.me.thumbnail); this.popups.guest.setProfile(this.me.thumbnail); this.popups.setting.setProfile(this.me.thumbnail); } accountButton?.click(); // close menu }, 0); }, /* hooks */ start({ ipc }) { this.ipc = ipc; this.showPrompt = (title: string, label: string) => ipc.invoke('music-together:prompt', title, label) as Promise; this.api = document.querySelector('ytmusic-app'); /* setup */ document .querySelector('#right-content > ytmusic-settings-button') ?.insertAdjacentHTML('beforebegin', settingHTML); const setting = document.querySelector( '#music-together-setting-button', ); const icon = document.querySelector( '#music-together-setting-button > svg', ); const spinner = document.querySelector( '#music-together-setting-button > tp-yt-paper-spinner-lite', ); if (!setting || !icon || !spinner) { console.warn('Music Together: Cannot inject html'); console.log(setting, icon, spinner); return; } this.elements = { setting, icon, spinner, }; this.stateInterval = window.setInterval(() => { if (this.connection?.mode !== 'host') return; const index = this.queue?.selectedIndex ?? 0; this.connection.broadcast('SYNC_PROGRESS', { progress: this.playerApi?.getCurrentTime(), state: this.playerApi?.getPlayerState(), index, }); }, 1000); /* UI */ const hostPopup = createHostPopup({ onItemClick: (id) => { if (id === 'music-together-close') { this.onStop(); this.api?.toastService?.show( t('plugins.music-together.toast.closed'), ); hostPopup.dismiss(); } if (id === 'music-together-copy-id') { navigator.clipboard .writeText(this.connection?.id ?? '') .then(() => { this.api?.toastService?.show( t('plugins.music-together.toast.id-copied'), ); hostPopup.dismiss(); }) .catch(() => { this.api?.toastService?.show( t('plugins.music-together.toast.id-copy-failed'), ); hostPopup.dismiss(); }); } if (id === 'music-together-permission') { if (this.permission === 'all') this.permission = 'host-only'; else if (this.permission === 'playlist') this.permission = 'all'; else if (this.permission === 'host-only') this.permission = 'playlist'; this.connection?.broadcast('PERMISSION', this.permission); hostPopup.setPermission(this.permission); guestPopup.setPermission(this.permission); settingPopup.setPermission(this.permission); const permissionLabel = t( `plugins.music-together.menu.permission.${this.permission}`, ); this.api?.toastService?.show( t('plugins.music-together.toast.permission-changed', { permission: permissionLabel, }), ); const item = hostPopup.items.find((it) => it?.element.id === id); if (item?.type === 'item') { item.setText(t('plugins.music-together.menu.set-permission')); } } }, }); const guestPopup = createGuestPopup({ onItemClick: (id) => { if (id === 'music-together-disconnect') { this.onStop(); this.api?.toastService?.show( t('plugins.music-together.toast.disconnected'), ); guestPopup.dismiss(); } }, }); const settingPopup = createSettingPopup({ onItemClick: async (id) => { if (id === 'music-together-host') { settingPopup.dismiss(); this.showSpinner(); const result = await this.onHost(); this.hideSpinner(); if (result) { navigator.clipboard .writeText(this.connection?.id ?? '') .then(() => { this.api?.toastService?.show( t('plugins.music-together.toast.id-copied'), ); hostPopup.showAtAnchor(setting); }) .catch(() => { this.api?.toastService?.show( t('plugins.music-together.toast.id-copy-failed'), ); hostPopup.showAtAnchor(setting); }); } else { this.api?.toastService?.show( t('plugins.music-together.toast.host-failed'), ); } } if (id === 'music-together-join') { settingPopup.dismiss(); this.showSpinner(); const result = await this.onJoin(); this.hideSpinner(); if (result) { this.api?.toastService?.show( t('plugins.music-together.toast.joined'), ); guestPopup.showAtAnchor(setting); } else { this.api?.toastService?.show( t('plugins.music-together.toast.join-failed'), ); } } }, }); this.popups = { host: hostPopup, guest: guestPopup, setting: settingPopup, }; setting.addEventListener('click', () => { let popup = settingPopup; if (this.connection?.mode === 'host') popup = hostPopup; if (this.connection?.mode === 'guest') popup = guestPopup; if (popup.isShowing()) popup.dismiss(); else popup.showAtAnchor(setting); }); /* account data getter */ this.initMyProfile(); }, onPlayerApiReady(playerApi) { this.queue = new Queue({ owner: { id: this.connection?.id ?? '', ...this.me!, }, getProfile: (id) => this.profiles[id], }); this.playerApi = playerApi; this.playerApi.addEventListener( 'onStateChange', this.videoStateChangeListener, ); document.addEventListener('videodatachange', this.videoChangeListener); }, stop() { const dividers = Array.from( document.querySelectorAll('.music-together-divider'), ); dividers.forEach((divider) => divider.remove()); this.elements.setting?.remove(); this.onStop(); if (typeof this.stateInterval === 'number') clearInterval(this.stateInterval); if (this.playerApi) this.playerApi.removeEventListener( 'onStateChange', this.videoStateChangeListener, ); if (this.videoChangeListener) document.removeEventListener( 'videodatachange', this.videoChangeListener, ); }, }, });