spotiqueue/static/index.html

542 lines
No EOL
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SpotiQueue</title>
<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>
li.song.draggable-mirror {
@apply bg-base-200
}
</style>
</head>
<body>
<!-- login page -->
<div id="m-login" class="w-full h-dvh flex items-center justify-center">
<div class="card bg-base-200">
<div class="card-body">
<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>
<!-- Main page -->
<div id="m-page" class="hidden w-full h-dvh flex flex-col p-2">
<!-- Desktop navbar -->
<div class="hidden md:block pb-2 w-full flex-none">
<div class="navbar bg-base-300 shadow-sm rounded-md">
<div class="navbar-start">
<a class="btn btn-ghost text-xl">SpotiQueue</a>
</div>
<div class="navbar-center w-1/2">
<form class="join flex-grow" style="anchor-name: --search-nav;" id="search-nav-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-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>
<!-- Main content -->
<div class="flex-grow flex flex-col md:flex-row-reverse gap-4 m-2">
<div class="flex-3 bg-base-200 rounded-md shadow-md">
<ul class="list bg-base-100 rounded-box shadow-md m-1 overflow-y-scroll" id="songlist-merged">
<li class="p-4 pb-2 text-xs opacity-60 tracking-wide">Merged Queue</li>
</ul>
</div>
<div class="flex-5 md:flex-4 lg:flex-5 bg-base-200 rounded-md shadow-md">
<ul class="list bg-base-100 rounded-box shadow-md m-1 overflow-y-scroll" id="songlist-personal">
<li class="p-4 pb-2 text-xs opacity-60 tracking-wide">Personal Queue</li>
</ul>
</div>
</div>
<!-- Mobile dock -->
<div class="dock md:hidden justify-self-end z-0 static">
<button class="dock-active">
<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>
</button>
<button 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>
</button>
<button 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>
</button>
</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>
</dialog>
</div>
<!-- Custom Components -->
<div class="hidden">
<!-- template song personal -->
<template id="template-song-personal">
<li class="list-row song " draggable="true">
<div class="drag-handle cursor-move select-none touch-none text-2xl opacity-50 mr-2">⋮⋮</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">Artistname</div>
</div>
<button 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.2em]">
<path fill="currentColor"
d="M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zm2-4h2V8H9zm4 0h2V8h-2z" />
</svg>
</button>
</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">
+
</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 username = (window.localStorage.username ?? "").trim();
let sessionOvertaken = false;
let emptiQueue = {
trackName: "Loneliness",
authorName: "Empty Queue"
}
// REACTIVE VARIABLES
const queues = {
_personal: [],
_merged: [],
get personal() {
return this._personal
},
set personal(newV) {
this._personal = newV;
(DOMMainSongPersonal.querySelectorAll(".song") ?? []).forEach(v => {
DOMMainSongPersonal.removeChild(v);
})
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()
DOMMainSongPersonal.querySelector("button")?.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("button").addEventListener("click", () => removeSongFromQueue(index))
DOMMainSongPersonal.append(el);
})
},
get merged() {
return this._merged
},
set merged(newV) {
this._merged = newV;
(DOMMainSongMerged.querySelectorAll(".song") ?? []).forEach(v => {
DOMMainSongMerged.removeChild(v);
})
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() {
ws = new WebSocket(`ws://${location.host}/ws/user`);
let willReconnect = true;
ws.onopen = () => {
connected.value = 0;
ws.send(JSON.stringify({c: 'login', d: {name: username}}));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.c === 'ping') {ws.send(JSON.stringify({c: 'pong'})); return;}
else if (msg.c === 'getqueue') {
console.log(msg.d)
queues.personal = msg.d.personal || [];
queues.merged = msg.d.merged || [];
}
else if (msg.c === "logout") {
mpAlert("Session takeover", "You've been logged in on another device. Please reload this page to reconnect.")
sessionOvertaken = true;
}
else if (msg.c === "status") {
connected.value = msg.d.status ? 0 : 1;
}
else if (msg.c === "search") {
DOMSearchNavDropown.innerHTML = "";
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 = () => {
connected.value = 2;
if (sessionOvertaken) return;
setTimeout(connect, 3000);
}
}
function login(e, auto) {
e?.preventDefault();
username = (username === "" ? DOMLoginInput.value : username).trim();
if (username === "" && !auto) {
mAlert("Error", "You've entered an invalid nickname. Please try again.");
return;
} else if (username === "") return;
window.localStorage.username = username;
DOMLoginPage.style.display = "none";
DOMMainPage.style.display = "flex";
DOMLoginInput.value = ""
connect();
}
function logout(e) {
e.preventDefault();
username = "";
window.localStorage.username = "";
location.reload();
}
function removeSongFromQueue(index) {
console.log(index)
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) {
this.splice(to, 0, this.splice(from, 1)[0]);
};
let sortable = new Sortable(DOMMainSongPersonal, {
draggable: '.song',
handle: '.drag-handle',
/*mirror: {
constrainDimensions: true,
}*/
});
sortable.on("sortable:start", (evt) => {
if (evt.dragEvent.source.draggable) return;
evt.cancel()
})
sortable.on('sortable:stop', (evt) => {
setTimeout(() => {
const {oldIndex, newIndex} = evt;
queues.personal.move(oldIndex, newIndex);
if (queues.personal.length > 0) {
ws?.send(JSON.stringify({c: 'setqueue', d: {queue: queues.personal}}));
}
}, 0)
});
DOMLoginForm.addEventListener("submit", e => login(e, false));
[DOMMainNavLogout, DOMMainDockLogout].forEach(v => v.addEventListener("click", logout))
document.body.onload = () => login(null, true);
DOMSearchNavForm.addEventListener("submit", navSearch)
DOMSearchDockForm.addEventListener("submit", dockSearch)
DOMSearchDockButton.addEventListener("click", () => {
DOMSearchDockSongs.innerHTML = '';
DOMSearchDockModal.showModal()
DOMSearchDockInput.focus();
setTimeout(window.scrollTo({left: 0, top: document.body.scrollHeight, behavior: "smooth"}), 500);
})
</script>
</body>
</html>