const DEFAULT_WS_URL = localStorage.getItem("spotiqueueUrl") ?? "ws://localhost:8000/ws/spotify"; const DEFAULT_RECONNECT_MS = 1000; class SpotiQueue { constructor(wsUrl = DEFAULT_WS_URL, reconnectMs = DEFAULT_RECONNECT_MS) { this.wsUrl = wsUrl; this.reconnectMs = reconnectMs; this.socket = null; this.reconnectInterval = null; this.closing = false; this.startedPlaying = false; this.button = null; this._onOpen = this._onOpen.bind(this); this._onMessage = this._onMessage.bind(this); this._onError = this._onError.bind(this); this._onClose = this._onClose.bind(this); } parseCommand(commandStr) { try { const parsed = JSON.parse(commandStr); if ( typeof parsed === "object" && parsed !== null && typeof parsed.c === "string" && (!("d" in parsed) || typeof parsed.d === "object") ) { return parsed; } return null; } catch { return null; } } connect() { console.log("[SpotiQueue] Trying to connect to server..."); if ( this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) ) { return; } this.socket = new WebSocket(this.wsUrl); this.socket.addEventListener("open", this._onOpen); this.socket.addEventListener("message", this._onMessage); this.socket.addEventListener("error", this._onError); this.socket.addEventListener("close", this._onClose); } stop() { if (this.reconnectInterval) { clearInterval(this.reconnectInterval); this.reconnectInterval = null; } if (this.socket) { this.closing = true; try { this.socket.close(); } catch (_e) { // empty } this.socket = null; } if (this.button) this.button.style.color = "#e22134"; console.log("[SpotiQueue] Disconnecting from server, Bye!"); } send(objOrString) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; const payload = typeof objOrString === "string" ? objOrString : JSON.stringify(objOrString); try { this.socket.send(payload); } catch (err) { console.error("[SpotiQueue] Failed to send:", err); } } _onOpen() { if (this.reconnectInterval) { clearInterval(this.reconnectInterval); this.reconnectInterval = null; } if (this.button) this.button.style.color = "#1DB954"; console.log("[SpotiQueue] Connected to server!"); } async _onMessage(event) { let data; try { data = JSON.parse(event.data); } catch { console.warn("[SpotiQueue] Received non-JSON message"); return; } if (data.c === "ping") { this.send({ c: "pong" }); return; } if (data.c === "next_song") { if (data.d && data.d.song) { try { Spicetify.Player.playUri(data.d.song); Spicetify.Player.setRepeat(false); this.startedPlaying = true; console.log("[SpotiQueue] New song received!"); } catch (err) { console.error("[SpotiQueue] Error playing received song:", err); } } else { Spicetify.Player.play(); this.startedPlaying = true; } } if (data.c === "search" && data.d && data.d.query && data.d.id) { const { query, id } = data.d; let songs = []; console.log("[SpotiQueue] Searching for", query); try { const { searchDesktop } = Spicetify.GraphQL.Definitions; const { data } = await Spicetify.GraphQL.Request(searchDesktop, { searchTerm: query, limit: 10, offset: 0, numberOfTopResults: 10, includeAudiobooks: false, includePreReleases: false, IncludeArtistHasConcertsField: false, includeAuthors: true, }); songs = data.searchV2.tracksV2.items.reduce((o, c) => { const item = c.item.data; if (!item.playability.playable) return o; const album = item.albumOfTrack; o.push({ name: item.name, uri: item.uri, artists: item.artists.items.map((v) => v.profile?.name), album: { name: album.name, coverUrl: album.coverArt.sources .sort((a, b) => b.height - a.height) .map((v) => v.url)[0], }, }); return o; }, []); } catch (e) { console.log("[SpotiQueue] Search error", e); // _ } console.log("[SpotiQueue] Found", songs); this.send({ c: "search", d: { id: id, results: songs, }, }); } } _onError(e) { try { console.log(e); if (this.socket) this.socket.close(); } catch { // ignore } } _onClose() { console.log( "[SpotiQueue] closed connection", this.reconnectInterval, this.closing, ); if (!this.reconnectInterval && !this.closing) { this.reconnectInterval = setInterval( () => this.connect(), this.reconnectMs, ); } if (this.button && !this.closing) { this.button.style.color = ""; } this.closing = false; } initUi() { const btn = new Spicetify.Topbar.Button("SpotiQueue", "enhance", () => {}); this.button = btn.button; this.button.style.color = "#e22134"; this.button.addEventListener("click", (e) => { console.log(e.pointerId); if (this.socket) { this.stop(); return; } if (Spicetify.GraphQL.Definitions.searchDesktop === undefined) { Spicetify.Platform.History.push("/search/"); setTimeout(() => { this.button.click(); Spicetify.Platform.History.goBack(); }, 200); return; } this.closing = false; this.button.style.color = ""; this.connect(); }); this.button.addEventListener("contextmenu", (e) => { e.preventDefault(); const div = document.createElement("div"); div.innerHTML = `
`; Spicetify.PopupModal.display({ title: "WebSocket URL", content: div }); div.querySelector("input").value = this.wsUrl div.querySelector("button").onclick = () => { const newUrl = div.querySelector("input").value.trim(); if (!newUrl) return; localStorage.setItem("spotiqueueUrl", newUrl); this.wsUrl = newUrl; this.stop(); Spicetify.PopupModal.hide(); Spicetify.showNotification("Saved!"); }; }); Spicetify.Player.addEventListener("songchange", (info) => { console.log(info); if ( this.socket && this.socket.readyState === WebSocket.OPEN && !this.startedPlaying ) { Spicetify.Player.pause(); console.log("[SpotiQueue] Requesting new song..."); this.send({ c: "next_song" }); } this.startedPlaying = false; }); } } (function init() { if ( !Spicetify.Player || !Spicetify.Platform || !Spicetify.GraphQL || !Spicetify.GraphQL.Request || !Spicetify.GraphQL.Definitions ) { setTimeout(init, 100); console.log("[SpotiQueue] loading extension... "); return; } globalThis.spotiQueue = new SpotiQueue(); globalThis.spotiQueue.initUi(); })();