// NAME: WebNowPlaying
// AUTHOR: khanhas, keifufu (based on https://github.com/keifufu/WebNowPlaying-Redux)
// DESCRIPTION: Provides media information and controls to WebNowPlaying-Redux-Rainmeter, but also supports WebNowPlaying for Rainmeter 0.5.0 and older.
///
(function WebNowPlaying() {
if (!Spicetify.CosmosAsync || !Spicetify.Platform.LibraryAPI) {
setTimeout(WebNowPlaying, 500);
return;
}
const socket = new WNPReduxWebSocket();
window.addEventListener("beforeunload", () => {
socket.close();
});
})();
class WNPReduxWebSocket {
_ws = null;
cache = new Map();
reconnectCount = 0;
updateInterval = null;
communicationRevision = null;
connectionTimeout = null;
reconnectTimeout = null;
isClosed = false;
spicetifyInfo = {
player: "Spotify Desktop",
state: "STOPPED",
title: "",
artist: "",
album: "",
cover: "",
duration: "0:00",
// position and volume are fetched in sendUpdate()
position: "0:00",
volume: 100,
rating: 0,
repeat: "NONE",
shuffle: false,
};
constructor() {
this.init();
Spicetify.Player.addEventListener("songchange", ({ data }) => this.updateSpicetifyInfo(data));
Spicetify.Player.addEventListener("onplaypause", ({ data }) => this.updateSpicetifyInfo(data));
}
updateSpicetifyInfo(data) {
if (!data?.item?.metadata) return;
const meta = data.item.metadata;
this.spicetifyInfo.title = meta.title;
this.spicetifyInfo.album = meta.album_title;
this.spicetifyInfo.duration = timeInSecondsToString(Math.round(Number.parseInt(meta.duration) / 1000));
this.spicetifyInfo.state = !data.isPaused ? "PLAYING" : "PAUSED";
this.spicetifyInfo.repeat = data.repeat === 2 ? "ONE" : data.repeat === 1 ? "ALL" : "NONE";
this.spicetifyInfo.shuffle = data.shuffle;
this.spicetifyInfo.artist = meta.artist_name;
let artistCount = 1;
while (meta[`artist_name:${artistCount}`]) {
this.spicetifyInfo.artist += `, ${meta[`artist_name:${artistCount}`]}`;
artistCount++;
}
if (!this.spicetifyInfo.artist) this.spicetifyInfo.artist = meta.album_title; // Podcast
Spicetify.Platform.LibraryAPI.contains(data.item.uri).then(([added]) => {
this.spicetifyInfo.rating = added ? 5 : 0;
});
const cover = meta.image_xlarge_url;
if (cover?.indexOf("localfile") === -1) this.spicetifyInfo.cover = `https://i.scdn.co/image/${cover.substring(cover.lastIndexOf(":") + 1)}`;
else this.spicetifyInfo.cover = "";
}
init() {
try {
this._ws = new WebSocket("ws://localhost:8974");
this._ws.onopen = this.onOpen.bind(this);
this._ws.onclose = this.onClose.bind(this);
this._ws.onerror = this.onError.bind(this);
this._ws.onmessage = this.onMessage.bind(this);
} catch {
this.retry();
}
}
close(cleanupOnly = false) {
if (!cleanupOnly) this.isClosed = true;
this.cache = new Map();
this.communicationRevision = null;
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
if (this.connectionTimeout) clearTimeout(this.connectionTimeout);
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
}
}
// Clean up old variables and retry connection
retry() {
if (this.isClosed) return;
this.close(true);
// Reconnects once per second for 30 seconds, then with a exponential backoff of (2^reconnectAttempts) up to 60 seconds
this.reconnectTimeout = setTimeout(
() => {
this.init();
this.reconnectAttempts += 1;
},
Math.min(1000 * (this.reconnectAttempts <= 30 ? 1 : 2 ** (this.reconnectAttempts - 30)), 60000)
);
}
send(data) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
this._ws.send(data);
}
onOpen() {
this.reconnectCount = 0;
this.updateInterval = setInterval(this.sendUpdate.bind(this), 500);
// If no communication revision is received within 1 second, assume it's WNP for Rainmeter < 0.5.0 (legacy)
this.connectionTimeout = setTimeout(() => {
if (this.communicationRevision === null) this.communicationRevision = "legacy";
}, 1000);
}
onClose() {
this.retry();
}
onError() {
this.retry();
}
onMessage(event) {
if (this.communicationRevision) {
switch (this.communicationRevision) {
case "legacy":
OnMessageLegacy(this, event.data);
break;
case "1":
OnMessageRev1(this, event.data);
break;
}
// Sending an update immediately would normally do nothing, as it takes some time for
// spicetifyInfo to be updated via the Cosmos subscription. However, we try to
// optimistically update spicetifyInfo after receiving events.
this.sendUpdate();
} else {
if (event.data.startsWith("Version:")) {
// 'Version:' WNP for Rainmeter 0.5.0 (legacy)
this.communicationRevision = "legacy";
} else if (event.data.startsWith("ADAPTER_VERSION ")) {
// Any WNPRedux adapter will send 'ADAPTER_VERSION ;WNPRLIB_REVISION ' after connecting
this.communicationRevision = event.data.split(";")[1].split(" ")[1];
} else {
// The first message wasn't version related, so it's probably WNP for Rainmeter < 0.5.0 (legacy)
this.communicationRevision = "legacy";
}
}
}
sendUpdate() {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
switch (this.communicationRevision) {
case "legacy":
SendUpdateLegacy(this);
break;
case "1":
SendUpdateRev1(this);
break;
}
}
}
function OnMessageLegacy(self, message) {
// Quite lengthy functions because we optimistically update spicetifyInfo after receiving events.
try {
const [type, data] = message.toUpperCase().split(" ");
switch (type) {
case "PLAYPAUSE": {
Spicetify.Player.togglePlay();
self.spicetifyInfo.state = self.spicetifyInfo.state === "PLAYING" ? "PAUSED" : "PLAYING";
break;
}
case "NEXT":
Spicetify.Player.next();
break;
case "PREVIOUS":
Spicetify.Player.back();
break;
case "SETPOSITION": {
// Example string: SetPosition 34:SetProgress 0,100890207715134:
const [, positionPercentage] = message.toUpperCase().split(":")[1].split("SETPROGRESS ");
Spicetify.Player.seek(Number.parseFloat(positionPercentage.replace(",", ".")));
break;
}
case "SETVOLUME":
Spicetify.Player.setVolume(Number.parseInt(data) / 100);
break;
case "REPEAT": {
Spicetify.Player.toggleRepeat();
self.spicetifyInfo.repeat = self.spicetifyInfo.repeat === "NONE" ? "ALL" : self.spicetifyInfo.repeat === "ALL" ? "ONE" : "NONE";
break;
}
case "SHUFFLE": {
Spicetify.Player.toggleShuffle();
self.spicetifyInfo.shuffle = !self.spicetifyInfo.shuffle;
break;
}
case "TOGGLETHUMBSUP": {
Spicetify.Player.toggleHeart();
self.spicetifyInfo.rating = self.spicetifyInfo.rating === 5 ? 0 : 5;
break;
}
// Spotify doesn't have a negative rating
// case 'TOGGLETHUMBSDOWN': break
case "RATING": {
const rating = Number.parseInt(data);
const isLiked = self.spicetifyInfo.rating > 3;
if (rating >= 3 && !isLiked) Spicetify.Player.toggleHeart();
else if (rating < 3 && isLiked) Spicetify.Player.toggleHeart();
self.spicetifyInfo.rating = rating;
break;
}
}
} catch (e) {
self.send(`Error:Error sending event to ${self.spicetifyInfo.player}`);
self.send(`ErrorD:${e}`);
}
}
function SendUpdateLegacy(self) {
if (!Spicetify.Player.data && cache.get("state") !== 0) {
cache.set("state", 0);
ws.send("STATE:0");
return;
}
self.spicetifyInfo.position = timeInSecondsToString(Math.round(Spicetify.Player.getProgress() / 1000));
self.spicetifyInfo.volume = Math.round(Spicetify.Player.getVolume() * 100);
for (const key of Object.keys(self.spicetifyInfo)) {
try {
let value = self.spicetifyInfo[key];
// For numbers, round it to an integer
if (typeof value === "number") value = Math.round(value);
// Conversion to legacy values
if (key === "state") value = value === "PLAYING" ? 1 : value === "PAUSED" ? 2 : 0;
else if (key === "repeat") value = value === "ALL" ? 2 : value === "ONE" ? 1 : 0;
else if (key === "shuffle") value = value ? 1 : 0;
// Check for null, and not just falsy, because 0 and '' are falsy
if (value !== null && value !== self.cache.get(key)) {
self.send(`${key.toUpperCase()}:${value}`);
self.cache.set(key, value);
}
} catch (e) {
self.send(`Error: Error updating ${key} for ${self.spicetifyInfo.player}`);
self.send(`ErrorD:${e}`);
}
}
}
function OnMessageRev1(self, message) {
// Quite lengthy functions because we optimistically update spicetifyInfo after receiving events.
const [type, data] = message.split(" ");
try {
switch (type) {
case "TOGGLE_PLAYING": {
Spicetify.Player.togglePlay();
self.spicetifyInfo.state = self.spicetifyInfo.state === "PLAYING" ? "PAUSED" : "PLAYING";
break;
}
case "NEXT":
Spicetify.Player.next();
break;
case "PREVIOUS":
Spicetify.Player.back();
break;
case "SET_POSITION": {
const [, positionPercentage] = data.split(":");
Spicetify.Player.seek(Number.parseFloat(positionPercentage.replace(",", ".")));
break;
}
case "SET_VOLUME":
Spicetify.Player.setVolume(Number.parseInt(data) / 100);
break;
case "TOGGLE_REPEAT": {
Spicetify.Player.toggleRepeat();
self.spicetifyInfo.repeat = self.spicetifyInfo.repeat === "NONE" ? "ALL" : self.spicetifyInfo.repeat === "ALL" ? "ONE" : "NONE";
break;
}
case "TOGGLE_SHUFFLE": {
Spicetify.Player.toggleShuffle();
self.spicetifyInfo.shuffle = !self.spicetifyInfo.shuffle;
break;
}
case "TOGGLE_THUMBS_UP": {
Spicetify.Player.toggleHeart();
self.spicetifyInfo.rating = self.spicetifyInfo.rating === 5 ? 0 : 5;
break;
}
// Spotify doesn't have a negative rating
// case 'TOGGLE_THUMBS_DOWN': break
case "SET_RATING": {
const rating = Number.parseInt(data);
const isLiked = self.spicetifyInfo.rating > 3;
if (rating >= 3 && !isLiked) Spicetify.Player.toggleHeart();
else if (rating < 3 && isLiked) Spicetify.Player.toggleHeart();
self.spicetifyInfo.rating = rating;
break;
}
}
} catch (e) {
self.send(`ERROR Error sending event to ${self.spicetifyInfo.player}`);
self.send(`ERRORDEBUG ${e}`);
}
}
function SendUpdateRev1(self) {
if (!Spicetify.Player.data && cache.get("state") !== "STOPPED") {
cache.set("state", "STOPPED");
ws.send("STATE STOPPED");
return;
}
self.spicetifyInfo.position = timeInSecondsToString(Math.round(Spicetify.Player.getProgress() / 1000));
self.spicetifyInfo.volume = Math.round(Spicetify.Player.getVolume() * 100);
for (const key of Object.keys(self.spicetifyInfo)) {
try {
let value = self.spicetifyInfo[key];
// For numbers, round it to an integer
if (typeof value === "number") value = Math.round(value);
// Check for null, and not just falsy, because 0 and '' are falsy
if (value !== null && value !== self.cache.get(key)) {
self.send(`${key.toUpperCase()} ${value}`);
self.cache.set(key, value);
}
} catch (e) {
self.send(`ERROR Error updating ${key} for ${self.spicetifyInfo.player}`);
self.send(`ERRORDEBUG ${e}`);
}
}
}
// Convert seconds to a time string acceptable to Rainmeter
function pad(num, size) {
return num.toString().padStart(size, "0");
}
function timeInSecondsToString(timeInSeconds) {
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)}`;
}