import * as WebSocket from 'ws'; /** * 0-pad a number. * @param {Number} number * @param {Number} length * @returns String */ const pad = (number: number, length: number) => String(number).padStart(length, '0'); /** * Convert seconds to a time string acceptable to Rainmeter * https://github.com/tjhrulz/WebNowPlaying-BrowserExtension/blob/master/WebNowPlaying.js#L50-L59 * @param {Number} timeInSeconds * @returns String */ const convertTimeToString = (timeInSeconds: number) => { const timeInMinutes = Math.floor(timeInSeconds / 60); if (timeInMinutes < 60) { return timeInMinutes + ":" + pad(Math.floor(timeInSeconds % 60), 2); } return Math.floor(timeInMinutes / 60) + ":" + pad(Math.floor(timeInMinutes % 60), 2) + ":" + pad(Math.floor(timeInSeconds % 60), 2); } export default class WebNowPlaying { /** * Base Plugin Details (Eventually implemented into a GUI in settings) */ public name: string = 'WebNowPlaying'; public description: string = 'Song info and playback control for the Rainmeter WebNowPlaying plugin.'; public version: string = '1.0.1'; public author: string = 'Zennn '; private _win: any; private ws?: WebSocket; private wsapiConn?: WebSocket; private playerName: string = 'Cider'; constructor() { console.debug(`[Plugin][${this.name}] Loading Complete.`); } /** * Blocks non-windows systems from running this plugin * @private * @decorator */ private static windowsOnly(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { if (process.platform !== 'win32') { descriptor.value = () => void 0; } } private sendSongInfo(attributes: any) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; const fields = ['STATE', 'TITLE', 'ARTIST', 'ALBUM', 'COVER', 'DURATION', 'POSITION', 'VOLUME', 'REPEAT', 'SHUFFLE']; fields.forEach((field) => { try { let value: any = ''; switch (field) { case 'STATE': value = attributes.status ? 1 : 2; break; case 'TITLE': value = attributes.name; break; case 'ARTIST': value = attributes.artistName; break; case 'ALBUM': value = attributes.albumName; break; case 'COVER': value = attributes.artwork.url.replace('{w}', attributes.artwork.width).replace('{h}', attributes.artwork.height); break; case 'DURATION': value = convertTimeToString(attributes.durationInMillis / 1000); break; case 'POSITION': value = convertTimeToString((attributes.durationInMillis - attributes.remainingTime) / 1000); break; case 'VOLUME': value = attributes.volume * 100; break; case 'REPEAT': value = attributes.repeatMode; break; case 'SHUFFLE': value = attributes.shuffleMode; break; } this.ws?.send(`${field}:${value}`); } catch (error) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(`Error:Error updating ${field} for ${this.playerName}`); this.ws.send(`ErrorD:${error}`); } } }); } private fireEvent(evt: WebSocket.MessageEvent) { if (!evt.data) return; const data = evt.data; let value: string = ''; if (data.split(/ (.+)/).length > 1) { value = data.split(/ (.+)/)[1]; } const eventName = data.split(' ')[0].toLowerCase(); try { switch (eventName) { case 'playpause': this._win.webContents.executeJavaScript('MusicKitInterop.playPause()').catch(console.error); break; case 'next': this._win.webContents.executeJavaScript('MusicKitInterop.next()').catch(console.error); break; case 'previous': this._win.webContents.executeJavaScript('MusicKitInterop.previous()').catch(console.error); break; case 'setposition': this._win.webContents.executeJavaScript(`MusicKit.getInstance().seekToTime(${parseFloat(value)})`); break; case 'setvolume': this._win.webContents.executeJavaScript(`MusicKit.getInstance().volume = ${parseFloat(value) / 100}`); break; case 'repeat': this._win.webContents.executeJavaScript('wsapi.toggleRepeat()').catch(console.error); break; case 'shuffle': this._win.webContents.executeJavaScript('wsapi.toggleShuffle()').catch(console.error); break; case 'togglethumbsup': // not implemented break; case 'togglethumbsdown': // not implemented break; case 'rating': // not implemented break; } } catch (error) { console.debug(error); if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(`Error:Error sending event to ${this.playerName}`); this.ws.send(`ErrorD:${error}`); } } } /** * Runs on app ready */ @WebNowPlaying.windowsOnly public onReady(win: any) { this._win = win; // Connect to Rainmeter plugin and retry on disconnect. const init = () => { try { this.ws = new WebSocket('ws://127.0.0.1:8974/'); let retry: NodeJS.Timeout; this.ws.onopen = () => { console.info('[WebNowPlaying] Connected to Rainmeter'); this.ws?.send(`PLAYER:${this.playerName}`); }; this.ws.onclose = () => { clearTimeout(retry); retry = setTimeout(init, 2000); }; this.ws.onerror = () => { clearTimeout(retry); this.ws?.close(); }; this.ws.onmessage = this.fireEvent?.bind(this); } catch (error) { console.error(error); } }; init(); // Connect to wsapi. Only used to update progress. try { this.wsapiConn = new WebSocket('ws://127.0.0.1:26369/'); this.wsapiConn.onopen = () => { console.info('[WebNowPlaying] Connected to wsapi'); }; this.wsapiConn.onmessage = (evt: WebSocket.MessageEvent) => { const response = JSON.parse(evt.data); if (response.type === 'playbackStateUpdate') { this.sendSongInfo(response.data); } }; } catch (error) { console.error(error); } console.debug(`[Plugin][${this.name}] Ready.`); } /** * Runs on app stop */ @WebNowPlaying.windowsOnly public onBeforeQuit() { if (this.ws) { this.ws.send('STATE:0'); this.ws.onclose = () => void 0; // disable onclose handler first to stop it from retrying this.ws.close(); } if (this.wsapiConn) { this.wsapiConn.close(); } console.debug(`[Plugin][${this.name}] Stopped.`); } /** * Runs on playback State Change * @param attributes Music Attributes (attributes.status = current state) */ @WebNowPlaying.windowsOnly public onPlaybackStateDidChange(attributes: any) { this.sendSongInfo(attributes); } /** * Runs on song change * @param attributes Music Attributes */ @WebNowPlaying.windowsOnly public onNowPlayingItemDidChange(attributes: any) { this.sendSongInfo(attributes); } }