Compare commits

..

No commits in common. "a94967ab3f91e385d8a53431be9467e8b96ed054" and "63dec0ede24d53cbe926fa26f69dba713a3f037f" have entirely different histories.

7 changed files with 361 additions and 965 deletions

View file

@ -1,5 +1,4 @@
const DEFAULT_WS_URL = const DEFAULT_WS_URL = "ws://localhost:8000/ws/spotify";
localStorage.getItem("spotiqueueUrl") ?? "ws://localhost:8000/ws/spotify";
const DEFAULT_RECONNECT_MS = 1000; const DEFAULT_RECONNECT_MS = 1000;
class SpotiQueue { class SpotiQueue {
@ -73,16 +72,14 @@ class SpotiQueue {
this.socket = null; this.socket = null;
} }
if (this.button) this.button.style.color = "#e22134";
console.log("[SpotiQueue] Disconnecting from server, Bye!"); console.log("[SpotiQueue] Disconnecting from server, Bye!");
} }
send(objOrString) { send(objOrString) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
const payload = const payload = (typeof objOrString === "string")
typeof objOrString === "string" ? objOrString
? objOrString : JSON.stringify(objOrString);
: JSON.stringify(objOrString);
try { try {
this.socket.send(payload); this.socket.send(payload);
} catch (err) { } catch (err) {
@ -114,19 +111,14 @@ class SpotiQueue {
return; return;
} }
if (data.c === "next_song") { if (data.c === "next_song" && data.d && data.d.song) {
if (data.d && data.d.song) { try {
try { Spicetify.Player.playUri(data.d.song);
Spicetify.Player.playUri(data.d.song); Spicetify.Player.setRepeat(false);
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; this.startedPlaying = true;
console.log("[SpotiQueue] New song received!");
} catch (err) {
console.error("[SpotiQueue] Error playing received song:", err);
} }
} }
@ -149,6 +141,7 @@ class SpotiQueue {
includeAuthors: true, includeAuthors: true,
}); });
console.log(data)
songs = data.searchV2.tracksV2.items.reduce((o, c) => { songs = data.searchV2.tracksV2.items.reduce((o, c) => {
const item = c.item.data; const item = c.item.data;
@ -158,12 +151,14 @@ class SpotiQueue {
o.push({ o.push({
name: item.name, name: item.name,
uri: item.uri, uri: item.uri,
artists: item.artists.items.map((v) => v.profile?.name), artists: item.artists.items.map(v => v.profile?.name),
album: { album: {
name: album.name, name: album.name,
coverUrl: album.coverArt.sources coverUrl: album.coverArt.sources.sort((a, b) =>
.sort((a, b) => b.height - a.height) b.height - a.height
.map((v) => v.url)[0], ).map((v) =>
v.url
)[0],
}, },
}); });
@ -176,17 +171,16 @@ class SpotiQueue {
console.log("[SpotiQueue] Found", songs); console.log("[SpotiQueue] Found", songs);
this.send({ this.send({
c: "search", "c": "search",
d: { "d": {
id: id, "id": id,
results: songs, "results": songs,
}, },
}); });
} }
} }
_onError(e) { _onError() {
try { try {
console.log(e);
if (this.socket) this.socket.close(); if (this.socket) this.socket.close();
} catch { } catch {
// ignore // ignore
@ -199,7 +193,6 @@ class SpotiQueue {
this.reconnectInterval, this.reconnectInterval,
this.closing, this.closing,
); );
if (!this.reconnectInterval && !this.closing) { if (!this.reconnectInterval && !this.closing) {
this.reconnectInterval = setInterval( this.reconnectInterval = setInterval(
() => this.connect(), () => this.connect(),
@ -207,68 +200,35 @@ class SpotiQueue {
); );
} }
if (this.button && !this.closing) { if (this.button) this.button.style.color = "";
this.button.style.color = "";
}
this.closing = false; this.closing = false;
} }
initUi() { initUi() {
const btn = new Spicetify.Topbar.Button("SpotiQueue", "enhance", () => {}); const btn = new Spicetify.Topbar.Button(
"SpotiQueue",
`<p>hi</p>`, // SVG icon or markup
() => {
if (!this.socket) {
if (Spicetify.GraphQL.Definitions.searchDesktop === undefined) {
Spicetify.showNotification("Please search something (e.g a song) before trying to use SpotiQueue. This will load required dependencies.")
return
}
this.connect();
this.button.innerText = "bye";
} else {
this.stop();
this.button.innerText = "hi";
}
},
);
this.button = btn.button; 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 = `
<div style="display: flex; flex-direction: row; gap: 0.5em;">
<input style="flex-grow: 1; box-sizing: border-box; padding: 8px;">
<button style="background: #1db954; color: #fff; border: none; margin: 0; padding: 0px 12px; border-radius: 6px"> Save </button>
</div>
`;
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) => { Spicetify.Player.addEventListener("songchange", (info) => {
console.log(info); console.log(info);
if ( if (
this.socket && this.socket && this.socket.readyState === WebSocket.OPEN &&
this.socket.readyState === WebSocket.OPEN &&
!this.startedPlaying !this.startedPlaying
) { ) {
Spicetify.Player.pause(); Spicetify.Player.pause();
@ -282,17 +242,14 @@ class SpotiQueue {
(function init() { (function init() {
if ( if (
!Spicetify.Player || !Spicetify.Player || !Spicetify.Platform || !Spicetify.GraphQL ||
!Spicetify.Platform || !Spicetify.GraphQL.Request || !Spicetify.GraphQL.Definitions
!Spicetify.GraphQL ||
!Spicetify.GraphQL.Request ||
!Spicetify.GraphQL.Definitions
) { ) {
setTimeout(init, 100); setTimeout(init, 100);
console.log("[SpotiQueue] loading extension... "); console.log("[SpotiQueue] loading extension... ");
return; return;
} }
globalThis.spotiQueue = new SpotiQueue(); const client = new SpotiQueue();
globalThis.spotiQueue.initUi(); client.initUi();
})(); })();

View file

@ -1,11 +1,12 @@
import { SpotifyWS } from "./spotify.ts" import { SpotifyWS } from "./spotify.ts"
import { HeartbeatWS } from "./heartbeat.ts"; import { HeartbeatWS } from "./heartbeat.ts";
import { PlayerManager } from "./playerManager.ts"; import { PlayerManager } from "./playerManager.ts";
import { serveDir } from "@std/http/file-server"; import { serveFile } from "@std/http/file-server";
import { UserWS } from "./user.ts"; import { UserWS } from "./user.ts";
const USER_PATTERN = new URLPattern({pathname: "/ws/user"}) const USER_PATTERN = new URLPattern({pathname: "/ws/user"})
const SPOTIFY_PATTERN = new URLPattern({pathname: "/ws/spotify"}) const SPOTIFY_PATTERN = new URLPattern({pathname: "/ws/spotify"})
const song = "spotify:track:2VxJVGiTsK6UyrxPJJ2lR9";
function commonWs(req: Request) { function commonWs(req: Request) {
const { socket, response } = Deno.upgradeWebSocket(req); const { socket, response } = Deno.upgradeWebSocket(req);
@ -32,11 +33,11 @@ Deno.serve((req) => {
const spState = spotify?.ws.readyState; const spState = spotify?.ws.readyState;
if (spState !== undefined && spState !== WebSocket.CLOSED) spotify?.ws.close(); if (spState !== undefined && spState !== WebSocket.CLOSED) spotify?.ws.close();
new SpotifyWS(socket); spotify = new SpotifyWS(socket, () => song);
new HeartbeatWS(socket); new HeartbeatWS(socket);
return response return response
} }
return serveDir(req, {fsRoot: "./static", urlRoot: ""}) return serveFile(req, "./static/index.html")
}) })

View file

@ -58,11 +58,6 @@ export class PlayerManager {
return song.song; return song.song;
} }
public broadcastStatus() {
for (const user of Object.values(this.userQueue)) {
user.userWS.updateStatus();
}
}
public updateMergedQueue() { public updateMergedQueue() {
this.generateMergedQueue(); this.generateMergedQueue();
this.broadcastMergedQueue(); this.broadcastMergedQueue();
@ -74,7 +69,7 @@ export class PlayerManager {
} }
private broadcastMergedQueue() { private broadcastMergedQueue() {
for (const user of Object.values(this.userQueue)) { for (const user of Object.values(this.userQueue)) {
user.userWS.updateQueue(); user.userWS.broadcastQueue(this.mergedQueue);
} }
} }

View file

@ -22,10 +22,7 @@ export class SpotifyWS {
this.onMessage = this.onMessage.bind(this); this.onMessage = this.onMessage.bind(this);
this.onClose = this.onClose.bind(this); this.onClose = this.onClose.bind(this);
this.onOpen = this.onOpen.bind(this);
this.ws.addEventListener("message", this.onMessage); this.ws.addEventListener("message", this.onMessage);
this.ws.addEventListener("open", this.onOpen);
this.ws.addEventListener("close", this.onClose); this.ws.addEventListener("close", this.onClose);
} }
@ -46,10 +43,7 @@ export class SpotifyWS {
public sendSong(uri?: string) { public sendSong(uri?: string) {
const song = uri ?? playerManager.getNext()?.uri; const song = uri ?? playerManager.getNext()?.uri;
if (!song) { if (!song) return;
this.send(buildCommand("next_song"));
return
};
this.send(buildCommand("next_song", { song })); this.send(buildCommand("next_song", { song }));
} }
@ -59,13 +53,8 @@ export class SpotifyWS {
this.ws.send(data); this.ws.send(data);
} }
private onOpen() {
globalThis.spotify = this;
playerManager.broadcastStatus();
}
private onClose() { private onClose() {
globalThis.spotify = undefined; globalThis.spotify = undefined;
playerManager.broadcastStatus();
} }
private onMessage(msg: MessageEvent<string>) { private onMessage(msg: MessageEvent<string>) {
const text = msg.data; const text = msg.data;

View file

@ -23,23 +23,24 @@ export class UserWS {
this.ws.send(buildCommand("logout")) this.ws.send(buildCommand("logout"))
this.ws.close(); this.ws.close();
} }
public broadcastQueue(queue: MergedQueue) {
this.ws.send(buildCommand("updatemergedqueue", {queue}))
}
public updateQueue() { public updateQueue() {
const queue = this.playerQueue?.queue; const queue = this.playerQueue?.queue;
if (!queue) return; if (!queue) return;
this.ws.send(buildCommand("getqueue", {personal: queue, merged: playerManager.mergedQueue })) this.ws.send(buildCommand("getqueue", {queue}))
}
public updateStatus() {
this.ws.send(buildCommand("status", {status: spotify !== undefined}))
} }
private spotifyConnected(): boolean {
return spotify === undefined;
}
private login(name: string) { private login(name: string) {
this.name = name; this.name = name;
this.loggedIn = true; this.loggedIn = true;
this.playerQueue = playerManager.login(name, this); this.playerQueue = playerManager.login(name, this);
this.updateQueue();
this.updateStatus();
} }
private async onMessage(msg: MessageEvent<string>) { private async onMessage(msg: MessageEvent<string>) {
const command = parseCommand(msg.data); const command = parseCommand(msg.data);
@ -72,11 +73,12 @@ export class UserWS {
const cmd = command as Command<{query: string}>; const cmd = command as Command<{query: string}>;
let songs = await spotify?.search(cmd.d.query).catch(_ => console.log("Failed to search...")) const songs = await spotify?.search(cmd.d.query)
if (songs === undefined) { if (songs === undefined) {
songs = [] this.ws.send(buildCommand("search", {songs: [], connected: false}))
} } else {
this.ws.send(buildCommand("search", {songs})) this.ws.send(buildCommand("search", {songs, connected: true}))
}
break break
} }
@ -102,11 +104,6 @@ export class UserWS {
this.playerQueue.queue.push(cmd.d.song); this.playerQueue.queue.push(cmd.d.song);
playerManager.updateMergedQueue(); playerManager.updateMergedQueue();
break
}
case "status": {
this.updateStatus();
break
} }
} }
} }

View file

@ -1,543 +1,352 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Spotify Queue</title>
<title>SpotiQueue</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css">
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style> <style>
li.song.draggable-mirror { * {
@apply bg-base-200 box-sizing: border-box;
margin: 0;
padding: 0;
font-family: system-ui, sans-serif;
}
body {
background: #121212;
color: #fff;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background: #1f1f1f;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 1.2rem;
}
main {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
footer {
background: #1f1f1f;
padding: 0.75rem 1rem;
display: flex;
gap: 0.5rem;
}
input[type=text],
button {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
}
input[type=text] {
flex: 1;
background: #2a2a2a;
color: #fff;
}
button {
background: #1db954;
color: #000;
cursor: pointer;
}
button:disabled {
background: #555;
cursor: not-allowed;
}
.song {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 6px;
background: #1a1a1a;
margin-bottom: 0.5rem;
cursor: grab;
touch-action: none;
}
.song.dragging {
opacity: 0.5;
}
.song img {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
}
.song div {
flex: 1;
}
.song .title {
font-weight: 600;
}
.song .artist {
font-size: 0.85rem;
opacity: 0.7;
}
.song .user {
font-size: 0.75rem;
opacity: 0.6;
margin-top: 0.2rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tabs button {
background: #2a2a2a;
color: #fff;
}
.tabs button.active {
background: #1db954;
color: #000;
}
.hidden {
display: none;
}
.login {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 300px;
margin: auto;
} }
</style> </style>
</head> </head>
<body> <body>
<!-- login page --> <div id="loginScreen" class="login">
<div id="m-login" class="w-full h-dvh flex items-center justify-center"> <h2>Enter your name</h2>
<div class="card bg-base-200"> <input id="usernameInput" type="text" placeholder="Username" />
<div class="card-body"> <button onclick="login()">Join</button>
<h2 class="card-title">Login</h2>
<form class="card-actions" id="login-form">
<div class="input">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g stroke-linejoin="round" stroke-linecap="round" stroke-width="2.5" fill="none" stroke="currentColor">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</g>
</svg>
<input id="login-input" type="text" required placeholder="Username">
</div>
<button class="btn btn-primary btn-block" type="submit">Join</button>
</form>
</div>
</div>
</div> </div>
<!-- Main page --> <div id="app" class="hidden">
<div id="m-page" class="hidden w-full h-dvh flex flex-col p-2"> <header>
<!-- Desktop navbar --> <h1>Spotify Queue</h1>
<div class="hidden md:block pb-2 w-full flex-none"> <button onclick="logout()">Logout</button>
<div class="navbar bg-base-300 shadow-sm rounded-md"> </header>
<div class="navbar-start">
<a class="btn btn-ghost text-xl">SpotiQueue</a>
</div>
<div class="navbar-center w-1/2"> <div class="tabs">
<form class="join flex-grow" style="anchor-name: --search-nav;" id="search-nav-form"> <button id="tabMy" class="active" onclick="switchTab('my')">My Queue</button>
<div class="input join-item w-full"> <button id="tabMerged" onclick="switchTab('merged')">Merged Queue</button>
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g stroke-linejoin="round" stroke-linecap="round" stroke-width="2.5" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input type="search" required placeholder="Search your favourite songs..." id="search-nav-input">
</div>
<button type="submit" class="btn btn-primary join-item">Search</button>
</form>
<ul class="dropdown list rounded-box bg-base-100 shadow-sm overflow-y-scroll max-h-[40dvh]" popover
id="search-nav-dropdown" style="position-anchor:--search-nav; width: anchor-size(width);">
</ul>
</div>
<div class="navbar-end">
<a class="btn btn-neutral main-logout" id="nav-logout">Logout</a>
</div>
</div>
</div> </div>
<main>
<!-- Main content --> <div id="myView">
<div class="flex-grow flex flex-col md:flex-row-reverse gap-4 m-2 min-h-0 overflow-hidden"> <div style="display:flex; gap:0.5rem; margin-bottom:1rem;">
<div class="flex-3 bg-base-200 rounded-md shadow-md min-h-0 overflow-hidden flex flex-col"> <input id="searchInput" type="text" placeholder="Search for songs…" />
<ul class="list bg-base-100 rounded-box shadow-md m-1 flex-1 overflow-y-auto" id="songlist-merged"> <button onclick="search()">Search</button>
<li class="p-4 pb-2 text-xs opacity-60 tracking-wide">Merged Queue</li> </div>
</ul> <div id="searchResults"></div>
<h3 style="margin-top:1rem;">My Queue</h3>
<div id="myQueue"></div>
</div> </div>
<div class="flex-5 md:flex-4 lg:flex-5 bg-base-200 rounded-md shadow-md min-h-0 overflow-hidden flex flex-col">
<ul class="list bg-base-100 rounded-box shadow-md m-1 flex-1 overflow-y-auto" id="songlist-personal">
<li class="p-4 pb-2 text-xs opacity-60 tracking-wide">Personal Queue</li>
</ul>
</div>
</div>
<!-- Mobile dock --> <div id="mergedView" class="hidden">
<div class="dock md:hidden justify-self-end z-0 static"> <h3>Merged Queue</h3>
<a class="dock-active"> <div id="mergedQueue"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256" class="size-[1.2em]">
<path fill="currentColor"
d="M32 64a8 8 0 0 1 8-8h176a8 8 0 0 1 0 16H40a8 8 0 0 1-8-8m104 56H40a8 8 0 0 0 0 16h96a8 8 0 0 0 0-16m0 64H40a8 8 0 0 0 0 16h96a8 8 0 0 0 0-16m112-24a8 8 0 0 1-3.76 6.78l-64 40A8 8 0 0 1 168 200v-80a8 8 0 0 1 12.24-6.78l64 40A8 8 0 0 1 248 160m-23.09 0L184 134.43v51.14Z" />
</svg>
<span class="dock-label">Queues</span>
</a>
<a id="search-dock-button">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="size-[1.2em]">
<path fill="currentColor"
d="M9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l5.6 5.6q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-5.6-5.6q-.75.6-1.725.95T9.5 16m0-2q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<span class="dock-label">Search</span>
</a>
<a id="dock-logout">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="size-[1.2em]">
<path fill="currentColor"
d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h6q.425 0 .713.288T12 4t-.288.713T11 5H5v14h6q.425 0 .713.288T12 20t-.288.713T11 21zm12.175-8H10q-.425 0-.712-.288T9 12t.288-.712T10 11h7.175L15.3 9.125q-.275-.275-.275-.675t.275-.7t.7-.313t.725.288L20.3 11.3q.3.3.3.7t-.3.7l-3.575 3.575q-.3.3-.712.288t-.713-.313q-.275-.3-.262-.712t.287-.688z" />
</svg>
<span class="dock-label">Logout</span>
</a>
</div>
<dialog id="pmodal" class="modal modal-bottom sm:modal-middle">
<div class="modal-box">
<h3 id="pmodal-header" class="text-lg font-bold"></h3>
<p id="pmodal-text" class="py-4"></p>
</div> </div>
</dialog> </main>
<!-- <footer> -->
<!-- <input id="searchFooter" type="text" placeholder="Search…" /> -->
<!-- <button onclick="searchFooter()">Search</button> -->
<!-- </footer> -->
</div> </div>
<!-- Custom Components --> <script>
<div class="hidden">
<!-- template song personal -->
<template id="template-song-personal">
<li class="list-row song " draggable="true">
<a class="btn btn-square btn-ghost drag-handle cursor-move select-none touch-none">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="size-[2em]">
<path fill="currentColor"
d="M5 15q-.425 0-.712-.288T4 14t.288-.712T5 13h14q.425 0 .713.288T20 14t-.288.713T19 15zm0-4q-.425 0-.712-.288T4 10t.288-.712T5 9h14q.425 0 .713.288T20 10t-.288.713T19 11z" />
</svg>
</a>
<div><img class="size-10 rounded-box"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="thumbnail"></div>
<div class="list-col-grow">
<div class="song-songname">Songname</div>
<div class="text-xs uppercase font-semibold opacity-60 song-artistname">Artistname</div>
</div>
<a class="btn btn-square btn-soft btn-error">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="size-[1.6em]">
<path fill="currentColor"
d="M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zm2-4h2V8H9zm4 0h2V8h-2z" />
</svg>
</a>
</li>
</template>
<!-- template song merged -->
<template id="template-song-merged">
<li class="list-row song">
<div class="text-4xl font-thin opacity-30 tabular-nums">01</div>
<div>
<img class="size-10 rounded-box"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="thumbnail">
</div>
<div class="list-col-grow">
<div class="song-songname">Songname</div>
<div class="text-xs uppercase font-semibold opacity-60 song-artistname">Artist name</div>
</div>
</li>
</template>
<!-- template song search -->
<template id="template-song-search">
<li class="list-row song">
<div>
<img class="size-10 rounded-box"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="thumbnail">
</div>
<div class="list-col-grow">
<div class="song-songname">Songname</div>
<div class="text-xs uppercase font-semibold opacity-60 song-artistname">Artist name</div>
</div>
<button class="btn btn-square btn-soft btn-success text-2xl">
+
</button>
</li>
</template>
</div>
<dialog id="modal" class="modal modal-bottom md:modal-middle">
<div class="modal-box">
<h3 id="modal-header" class="text-lg font-bold"></h3>
<p id="modal-text" class="py-4"></p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="search-dock-modal" class="modal modal-bottom md:modal-middle">
<div class="modal-box flex flex-col md:h-auto max-h-[50dvh]">
<ul id="search-dock-songs" class="list overflow-y-auto flex-grow">
</ul>
<div class="modal-action mt-4">
<div class="join flex-grow">
<form class="contents" style="anchor-name: --search-nav;" id="search-dock-form">
<div class="input join-item w-full">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g stroke-linejoin="round" stroke-linecap="round" stroke-width="2.5" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input type="search" required placeholder="Search your favourite songs..." id="search-dock-input">
</div>
<button type="submit" class="btn btn-primary join-item">Search</button>
</form>
<form method="dialog">
<button class="btn btn-neutral join-item text-2xl">×</button>
</form>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script type="module">
import Sortable from 'https://cdn.jsdelivr.net/npm/@shopify/draggable/build/esm/Sortable/Sortable.mjs';
const $ = document.querySelector.bind(document);
const $all = document.querySelectorAll.bind(document);
// ELEMENTS
const DOMModal = $("#modal");
const DOMModalHeader = $("#modal-header");
const DOMModalText = $("#modal-text");
const DOMPModal = $("#pmodal");
const DOMPModalHeader = $("#pmodal-header");
const DOMPModalText = $("#pmodal-text");
const DOMLoginPage = $("#m-login");
const DOMLoginInput = $("#login-input");
const DOMLoginForm = $("#login-form");
const DOMMainPage = $("#m-page");
const DOMMainSongPersonal = $("#songlist-personal")
const DOMMainSongMerged = $("#songlist-merged")
const DOMMainNavLogout = $("#nav-logout");
const DOMMainDockLogout = $("#dock-logout");
const DOMTemplateSongPersonal = $("#template-song-personal");
const DOMTemplateSongMerged = $("#template-song-merged");
const DOMTemplateSongSearch = $("#template-song-search");
const DOMSearchNavForm = $("#search-nav-form")
const DOMSearchNavDropown = $("#search-nav-dropdown")
const DOMSearchNavInput = $("#search-nav-input")
const DOMSearchDockSongs = $("#search-dock-songs")
const DOMSearchDockModal = $("#search-dock-modal")
const DOMSearchDockForm = $("#search-dock-form")
const DOMSearchDockInput = $("#search-dock-input")
const DOMSearchDockButton = $("#search-dock-button")
// VARIABLES
let ws; let ws;
let username = (window.localStorage.username ?? "").trim(); let username = '';
let sessionOvertaken = false; let myQueue = [];
let emptiQueue = { let mergedQueue = [];
trackName: "Loneliness", let searchResults = [];
authorName: "Empty Queue" let dragged = null;
}
// REACTIVE VARIABLES
const queues = {
_personal: [],
_merged: [],
get personal() {
return this._personal
},
set personal(newV) {
this._personal = newV;
(DOMMainSongPersonal.querySelectorAll(".song") ?? []).forEach(v => v.remove())
if (newV.length === 0) {
const el = document.importNode(DOMTemplateSongPersonal.content, true);
el.querySelector(".song-songname").innerText = emptiQueue.trackName;
el.querySelector(".song-artistname").innerText = emptiQueue.authorName;
el.querySelector("img").src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
DOMMainSongPersonal.append(el);
DOMMainSongPersonal.querySelector("img")?.remove()
const btns = DOMMainSongPersonal.querySelectorAll("a");
btns[0].classList = "text-4xl font-thin opacity-30 tabular-nums"
btns[0].innerHTML = "01";
btns[1].remove()
DOMMainSongPersonal.children[1].draggable = false;
return
}
newV.forEach((song, index) => {
const el = document.importNode(DOMTemplateSongPersonal.content, true);
el.querySelector(".song-songname").innerText = song.name;
el.querySelector(".song-artistname").innerText = song.artists.join(", ");
el.querySelector("img").src = song.album?.coverUrl ?? "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
el.querySelector("a.btn-error").addEventListener("click", () => removeSongFromQueue(index))
DOMMainSongPersonal.append(el);
})
},
get merged() {
return this._merged
},
set merged(newV) {
this._merged = newV;
(DOMMainSongMerged.querySelectorAll(".song") ?? []).forEach(v => v.remove())
if (newV.length === 0) {
const el = document.importNode(DOMTemplateSongMerged.content, true);
el.querySelector(".song-songname").innerText = emptiQueue.trackName;
el.querySelector(".song-artistname").innerText = emptiQueue.authorName;
el.querySelector("img").src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
DOMMainSongMerged.append(el);
DOMMainSongMerged.querySelector("img")?.remove()
DOMMainSongMerged.querySelector("button")?.remove()
return
}
newV.forEach((data, index) => {
const {user, song} = data;
const el = document.importNode(DOMTemplateSongMerged.content, true);
el.querySelector(".tabular-nums").innerText = (index + 1).toString().padStart(2, "0");
el.querySelector(".song-songname").innerText = song.name;
el.querySelector(".song-artistname").innerText = user + " - " + song.artists?.join(", ") ?? "";
el.querySelector("img").src = song.album?.coverUrl ?? "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
DOMMainSongMerged.append(el);
})
}
}
const connected = { // 0 = connected, 1 = servererror, 2 = disconnected
_value: 2,
get value() {
return this._value
},
set value(newV) {
this._value = newV;
if (sessionOvertaken) return
switch (newV) {
case 0:
DOMPModal.open = false;
break;
case 1:
mpAlert("Connection problem - Extension", "The SpotiQueue server lost connection to the host. Please reconnect the extension.");
break;
case 2:
mpAlert("Connection Problem - Server", "Lost connection to the SpotiQueue server. Please try to reconnect.");
break;
}
}
};
// FUNCTIONS
function mAlert(header, text) {
DOMModalHeader.innerText = header;
DOMModalText.innerText = text;
DOMModal.showModal();
}
function mpAlert(header, text) {
DOMPModalHeader.innerText = header;
DOMPModalText.innerText = text;
DOMPModal.showModal();
}
function connect() { function connect() {
ws = new WebSocket(`ws://${location.host}/ws/user`); ws = new WebSocket(`ws://${location.host}/ws/user`);
let willReconnect = true;
ws.onopen = () => { ws.onopen = () => {
connected.value = 0;
ws.send(JSON.stringify({c: 'login', d: {name: username}})); ws.send(JSON.stringify({c: 'login', d: {name: username}}));
ws.send(JSON.stringify({c: 'getqueue'}));
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.c === 'ping') {ws.send(JSON.stringify({c: 'pong'})); return;} if (msg.c === 'ping') {ws.send(JSON.stringify({c: 'pong'})); return;}
else if (msg.c === 'getqueue') { if (msg.c === 'search') {
queues.personal = msg.d.personal || []; searchResults = msg.d.songs || [];
queues.merged = msg.d.merged || []; renderSearch();
} }
else if (msg.c === "logout") { if (msg.c === 'getqueue') {
mpAlert("Session takeover", "You've been logged in on another device. Please reload this page to reconnect.") myQueue = msg.d.queue || [];
sessionOvertaken = true; renderMyQueue();
} }
else if (msg.c === "status") { if (msg.c === 'updatemergedqueue') {
connected.value = msg.d.status ? 0 : 1; console.log(msg)
mergedQueue = msg.d.queue || [];
if (document.getElementById('tabMerged').classList.contains('active')) renderMerged();
} }
else if (msg.c === "search") { if (msg.c === "logout") {
DOMSearchNavDropown.innerHTML = ""; location.reload();
DOMSearchDockSongs.innerHTML = "";
for (let song of msg.d.songs) {
const el = document.importNode(DOMTemplateSongSearch.content, true);
//el.querySelector(".tabular-nums").innerText = (index + 1).toString().padStart(2, "0");
el.querySelector(".song-songname").innerText = song.name;
el.querySelector(".song-artistname").innerText = song.artists?.join(", ") ?? "";
el.querySelector("img").src = song.album?.coverUrl ?? "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
const el1 = el.cloneNode(true)
DOMSearchNavDropown.append(el);
DOMSearchDockSongs.append(el1);
const handler = () => ws?.send(JSON.stringify({
c: "queuesong",
d: {song}
}))
const childrenD = DOMSearchNavDropown.children;
const childrenS = DOMSearchDockSongs.children;
childrenD[childrenD.length - 1]?.querySelector("button")?.addEventListener("click", handler);
childrenS[childrenS.length - 1]?.querySelector("button")?.addEventListener("click", handler);
}
DOMSearchDockSongs.scrollTo({top: 0})
} }
}; };
ws.onclose = () => { ws.onclose = () => setTimeout(connect, 3000);
connected.value = 2;
if (sessionOvertaken) return;
setTimeout(connect, 3000);
}
} }
function login(e, auto) { function login() {
e?.preventDefault(); const name = document.getElementById('usernameInput').value.trim();
username = (username === "" ? DOMLoginInput.value : username).trim(); if (!name) return;
if (username === "" && !auto) { username = name;
mAlert("Error", "You've entered an invalid nickname. Please try again."); document.getElementById('loginScreen').style.display = 'none';
return; document.getElementById('app').classList.remove('hidden');
} else if (username === "") return;
window.localStorage.username = username;
DOMLoginPage.style.display = "none";
DOMMainPage.style.display = "flex";
DOMLoginInput.value = ""
connect(); connect();
} }
function logout(e) {
e.preventDefault();
username = ""; function logout() {
window.localStorage.username = ""; username = '';
location.reload(); if (ws) ws.close();
} document.getElementById('app').classList.add('hidden');
function removeSongFromQueue(index) { document.getElementById('loginScreen').style.display = 'flex';
queues.personal.splice(index, 1)
queues.personal = [...queues.personal];
ws?.send(JSON.stringify({c: 'setqueue', d: {queue: queues.personal}}));
}
function navSearch(e) {
e.preventDefault();
const query = DOMSearchNavInput.value.trim();
if (query === "") return
ws?.send(JSON.stringify({
c: "search",
d: {query}
}))
DOMSearchNavDropown.innerHTML = '<div class="skeleton w-full h-16"></div>';
DOMSearchNavDropown.showPopover();
}
function dockSearch(e) {
e.preventDefault();
const query = DOMSearchDockInput.value.trim();
if (query === "") return
ws?.send(JSON.stringify({
c: "search",
d: {query}
}))
DOMSearchDockSongs.innerHTML = '<div class="skeleton w-full h-16 mb-4"></div>';
} }
Array.prototype.move = function (from, to) { function switchTab(tab) {
this.splice(to, 0, this.splice(from, 1)[0]); document.getElementById('tabMy').classList.toggle('active', tab === 'my');
}; document.getElementById('tabMerged').classList.toggle('active', tab === 'merged');
let sortable = new Sortable(DOMMainSongPersonal, { document.getElementById('myView').classList.toggle('hidden', tab !== 'my');
draggable: '.song', document.getElementById('mergedView').classList.toggle('hidden', tab !== 'merged');
handle: '.drag-handle', if (tab === 'merged') renderMerged();
/*mirror: { }
constrainDimensions: true,
}*/
});
sortable.on("sortable:start", (evt) => {
if (evt.dragEvent.source.draggable) return;
evt.cancel() function search() {
}) const q = document.getElementById('searchInput').value.trim();
sortable.on('sortable:stop', (evt) => { if (!q) return;
setTimeout(() => { ws.send(JSON.stringify({c: 'search', d: {query: q}}));
const {oldIndex, newIndex} = evt; }
queues.personal.move(oldIndex, newIndex); function searchFooter() {
const q = document.getElementById('searchFooter').value.trim();
if (!q) return;
document.getElementById('searchInput').value = q;
search();
switchTab('my');
}
if (queues.personal.length > 0) { function addSong(song) {
ws?.send(JSON.stringify({c: 'setqueue', d: {queue: queues.personal}})); myQueue.push(song);
} ws.send(JSON.stringify({c: 'queuesong', d: {song}}));
}, 0) ws.send(JSON.stringify({c: 'getqueue'}));
}); }
DOMLoginForm.addEventListener("submit", e => login(e, false)); function removeSong(idx) {
[DOMMainNavLogout, DOMMainDockLogout].forEach(v => v.addEventListener("click", logout)) myQueue.splice(idx, 1);
document.body.onload = () => login(null, true); ws.send(JSON.stringify({c: 'setqueue', d: {queue: myQueue}}));
renderMyQueue();
}
DOMSearchNavForm.addEventListener("submit", navSearch) function renderSearch() {
DOMSearchDockForm.addEventListener("submit", dockSearch) const el = document.getElementById('searchResults');
DOMSearchDockButton.addEventListener("click", () => { el.innerHTML = '';
DOMSearchDockSongs.innerHTML = ''; searchResults.forEach(s => {
DOMSearchDockModal.showModal() const div = document.createElement('div');
DOMSearchDockInput.focus(); div.className = 'song';
setTimeout(window.scrollTo({left: 0, top: document.body.scrollHeight, behavior: "smooth"}), 500); div.innerHTML = `
}) <img src="${s.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${s.name}</div>
<div class="artist">${s.artists.join(', ')}</div>
</div>
<button onclick='addSong(${JSON.stringify(s)})'>+</button>
`;
el.appendChild(div);
});
}
function renderMyQueue() {
const el = document.getElementById('myQueue');
el.innerHTML = '';
myQueue.forEach((s, i) => {
const div = document.createElement('div');
div.className = 'song';
div.draggable = true;
div.dataset.idx = i;
div.innerHTML = `
<img src="${s.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${s.name}</div>
<div class="artist">${s.artists.join(', ')}</div>
</div>
<button onclick="removeSong(${i})"></button>
`;
div.ondragstart = e => {dragged = +e.target.dataset.idx; e.target.classList.add('dragging');};
div.ondragend = e => e.target.classList.remove('dragging');
div.ondragover = e => e.preventDefault();
div.ondrop = e => {
e.preventDefault();
const to = +e.currentTarget.dataset.idx;
if (dragged === to) return;
const temp = myQueue[dragged];
myQueue.splice(dragged, 1);
myQueue.splice(to, 0, temp);
ws.send(JSON.stringify({c: 'setqueue', d: {queue: myQueue}}));
renderMyQueue();
};
el.appendChild(div);
});
}
function renderMerged() {
const el = document.getElementById('mergedQueue');
el.innerHTML = '';
mergedQueue.forEach(item => {
const div = document.createElement('div');
div.className = 'song';
div.innerHTML = `
<img src="${item.song.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${item.song.name}</div>
<div class="artist">${item.song.artists.join(', ')}</div>
<div class="user">Added by ${item.user}</div>
</div>
`;
el.appendChild(div);
});
}
function escSong(s) {
return encodeURIComponent(JSON.stringify(s).replace(/'/g, "\\'"));
}
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,352 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Spotify Queue</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: system-ui, sans-serif;
}
body {
background: #121212;
color: #fff;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background: #1f1f1f;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 1.2rem;
}
main {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
footer {
background: #1f1f1f;
padding: 0.75rem 1rem;
display: flex;
gap: 0.5rem;
}
input[type=text],
button {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
}
input[type=text] {
flex: 1;
background: #2a2a2a;
color: #fff;
}
button {
background: #1db954;
color: #000;
cursor: pointer;
}
button:disabled {
background: #555;
cursor: not-allowed;
}
.song {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 6px;
background: #1a1a1a;
margin-bottom: 0.5rem;
cursor: grab;
touch-action: none;
}
.song.dragging {
opacity: 0.5;
}
.song img {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
}
.song div {
flex: 1;
}
.song .title {
font-weight: 600;
}
.song .artist {
font-size: 0.85rem;
opacity: 0.7;
}
.song .user {
font-size: 0.75rem;
opacity: 0.6;
margin-top: 0.2rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tabs button {
background: #2a2a2a;
color: #fff;
}
.tabs button.active {
background: #1db954;
color: #000;
}
.hidden {
display: none;
}
.login {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 300px;
margin: auto;
}
</style>
</head>
<body>
<div id="loginScreen" class="login">
<h2>Enter your name</h2>
<input id="usernameInput" type="text" placeholder="Username" />
<button onclick="login()">Join</button>
</div>
<div id="app" class="hidden">
<header>
<h1>Spotify Queue</h1>
<button onclick="logout()">Logout</button>
</header>
<div class="tabs">
<button id="tabMy" class="active" onclick="switchTab('my')">My Queue</button>
<button id="tabMerged" onclick="switchTab('merged')">Merged Queue</button>
</div>
<main>
<div id="myView">
<div style="display:flex; gap:0.5rem; margin-bottom:1rem;">
<input id="searchInput" type="text" placeholder="Search for songs…" />
<button onclick="search()">Search</button>
</div>
<div id="searchResults"></div>
<h3 style="margin-top:1rem;">My Queue</h3>
<div id="myQueue"></div>
</div>
<div id="mergedView" class="hidden">
<h3>Merged Queue</h3>
<div id="mergedQueue"></div>
</div>
</main>
<!-- <footer> -->
<!-- <input id="searchFooter" type="text" placeholder="Search…" /> -->
<!-- <button onclick="searchFooter()">Search</button> -->
<!-- </footer> -->
</div>
<script>
let ws;
let username = '';
let myQueue = [];
let mergedQueue = [];
let searchResults = [];
let dragged = null;
function connect() {
ws = new WebSocket(`ws://${location.host}/ws/user`);
ws.onopen = () => {
ws.send(JSON.stringify({c: 'login', d: {name: username}}));
ws.send(JSON.stringify({c: 'getqueue'}));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.c === 'ping') {ws.send(JSON.stringify({c: 'pong'})); return;}
if (msg.c === 'search') {
searchResults = msg.d.songs || [];
renderSearch();
}
if (msg.c === 'getqueue') {
myQueue = msg.d.queue || [];
renderMyQueue();
}
if (msg.c === 'updatemergedqueue') {
console.log(msg)
mergedQueue = msg.d.queue || [];
if (document.getElementById('tabMerged').classList.contains('active')) renderMerged();
}
if (msg.c === "logout") {
location.reload();
}
};
ws.onclose = () => setTimeout(connect, 3000);
}
function login() {
const name = document.getElementById('usernameInput').value.trim();
if (!name) return;
username = name;
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('app').classList.remove('hidden');
connect();
}
function logout() {
username = '';
if (ws) ws.close();
document.getElementById('app').classList.add('hidden');
document.getElementById('loginScreen').style.display = 'flex';
}
function switchTab(tab) {
document.getElementById('tabMy').classList.toggle('active', tab === 'my');
document.getElementById('tabMerged').classList.toggle('active', tab === 'merged');
document.getElementById('myView').classList.toggle('hidden', tab !== 'my');
document.getElementById('mergedView').classList.toggle('hidden', tab !== 'merged');
if (tab === 'merged') renderMerged();
}
function search() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
ws.send(JSON.stringify({c: 'search', d: {query: q}}));
}
function searchFooter() {
const q = document.getElementById('searchFooter').value.trim();
if (!q) return;
document.getElementById('searchInput').value = q;
search();
switchTab('my');
}
function addSong(song) {
myQueue.push(song);
ws.send(JSON.stringify({c: 'queuesong', d: {song}}));
ws.send(JSON.stringify({c: 'getqueue'}));
}
function removeSong(idx) {
myQueue.splice(idx, 1);
ws.send(JSON.stringify({c: 'setqueue', d: {queue: myQueue}}));
renderMyQueue();
}
function renderSearch() {
const el = document.getElementById('searchResults');
el.innerHTML = '';
searchResults.forEach(s => {
const div = document.createElement('div');
div.className = 'song';
div.innerHTML = `
<img src="${s.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${s.name}</div>
<div class="artist">${s.artists.join(', ')}</div>
</div>
<button onclick='addSong(${JSON.stringify(s)})'>+</button>
`;
el.appendChild(div);
});
}
function renderMyQueue() {
const el = document.getElementById('myQueue');
el.innerHTML = '';
myQueue.forEach((s, i) => {
const div = document.createElement('div');
div.className = 'song';
div.draggable = true;
div.dataset.idx = i;
div.innerHTML = `
<img src="${s.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${s.name}</div>
<div class="artist">${s.artists.join(', ')}</div>
</div>
<button onclick="removeSong(${i})"></button>
`;
div.ondragstart = e => {dragged = +e.target.dataset.idx; e.target.classList.add('dragging');};
div.ondragend = e => e.target.classList.remove('dragging');
div.ondragover = e => e.preventDefault();
div.ondrop = e => {
e.preventDefault();
const to = +e.currentTarget.dataset.idx;
if (dragged === to) return;
const temp = myQueue[dragged];
myQueue.splice(dragged, 1);
myQueue.splice(to, 0, temp);
ws.send(JSON.stringify({c: 'setqueue', d: {queue: myQueue}}));
renderMyQueue();
};
el.appendChild(div);
});
}
function renderMerged() {
const el = document.getElementById('mergedQueue');
el.innerHTML = '';
mergedQueue.forEach(item => {
const div = document.createElement('div');
div.className = 'song';
div.innerHTML = `
<img src="${item.song.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${item.song.name}</div>
<div class="artist">${item.song.artists.join(', ')}</div>
<div class="user">Added by ${item.user}</div>
</div>
`;
el.appendChild(div);
});
}
function escSong(s) {
return encodeURIComponent(JSON.stringify(s).replace(/'/g, "\\'"));
}
</script>
</body>
</html>