This commit is contained in:
XenGi 2025-08-23 18:36:47 +02:00
commit fd4eb5b3e8
Signed by: xengi
SSH key fingerprint: SHA256:jxWM2RTHvxxcncXycwwWkP7HCWb4VREN05UGJTbIPZg
5 changed files with 195 additions and 272 deletions

437
static/css/style.css Normal file
View file

@ -0,0 +1,437 @@
:root {
--ribbon-width: 160px;
--ribbon-height: 80px;
--background-color: #041936;
--text-color: #bbb;
--input-background-color: #28374a;
--input-border-light: #545454;
--input-border-dark: #3a3a3a;
}
/* #################### */
/* #### structure ##### */
/* #################### */
html, body {
margin: 0;
height: 100%;
}
main {
height: 100%;
display: grid;
grid-auto-columns: 1fr;
grid-template-columns: 1fr 2fr;
grid-template-rows: var(--ribbon-height) 1fr 1fr;
gap: 0 0;
grid-template-areas: "nav nav" "queue queue" "browser result" "footer footer";
}
#queue {
grid-area: queue;
overflow: auto;
}
#nav {
grid-area: nav;
display: flex;
}
#nav > div {
width: var(--ribbon-width);
}
#result {
grid-area: result;
overflow: auto;
}
#browser {
grid-area: browser;
}
main footer {
grid-area: footer;
overflow: auto;
background-color: var(--background-color);
text-align: right;
}
table {
width: 100%;
}
#control-admin {
display: flex;
flex-direction: column;
}
#sanic-logo {
display: flex;
flex-grow: 1;
justify-content: flex-end;
}
#sanic-logo > div {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
#sanic-logo img {
max-width: 75%;
max-height: 75%;
margin: auto;
}
.spaced {
display: flex;
justify-content: space-between;
}
/* #################### */
/* ### pretty stuff ### */
/* #################### */
#control-progress {
width: var(--ribbon-width);
}
#control-volume {
width: 80px;
}
#nav > div.wide {
width: 280px;
}
#nav > div.wide div {
display: flex;
justify-content: space-between;
}
/* Disable arrows in input */
/* Chrome, Safari, Edge, Opera */
#control-xfade::-webkit-outer-spin-button,
#control-xfade::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
#control-xfade[type=number] {
-moz-appearance: textfield;
}
#control-xfade {
width: 2em;
}
#control-previous,
#control-play-pause,
#control-stop,
#control-next {
width: 2.5em;
height: 2.5em;
text-align: center;
}
/*
#control-track {
transform: translateX(100%);
-moz-transform: translateX(100%);
-webkit-transform: translateX(100%);
animation: scroll-left 20s linear infinite;
-moz-animation: scroll-left 2s linear infinite;
-webkit-animation: scroll-left 2s linear infinite;
}
@keyframes scroll-left {
0% {
transform: translateX(100%);
-moz-transform: translateX(100%);
-webkit-transform: translateX(100%);
}
100% {
transform: translateX(-100%);
-moz-transform: translateX(-100%);
-webkit-transform: translateX(-100%);
}
}
@-moz-keyframes scroll-left {
0% {
-moz-transform: translateX(100%);
}
100% {
-moz-transform: translateX(-100%);
}
}
@-webkit-keyframes scroll-left {
0% {
-webkit-transform: translateX(100%);
}
100% {
-webkit-transform: translateX(-100%);
}
}
*/
html, body {
background-color: #09101d;
color: var(--text-color);
scrollbar-color: #490b00 #09101d; /* only in firefox: https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color */
font-weight: normal;
font-family: Arial, Helvetica, sans-serif;
font-size: 12pt;
}
a {
color: var(--text-color);
text-decoration: none;
}
button {
background-color: var(--input-background-color);
color: var(--text-color);
border-top-color: var(--input-border-light);
border-right-color: var(--input-border-dark);
border-bottom-color: var(--input-border-dark);
border-left-color: var(--input-border-light);
}
button[disabled] {
color: var(--background-color);
}
button[disabled] .loader {
display: inline-block;
}
button .loader {
display: none;
}
.loader {
width: 10pt;
height: 10pt;
border: 3px solid var(--text-color);
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* borderless button used in queue */
.borderless {
border: none;
background-color: inherit;
cursor: pointer;
}
input[type=text] {
background-color: var(--input-background-color);
color: var(--text-color);
border: 1px solid black;
border-right-color: var(--input-border-light);
border-bottom-color: var(--input-border-light);
}
.marquee {
display: flex;
position: relative;
overflow: hidden;
white-space: nowrap;
width: var(--ribbon-width);
font-size: 10pt;
background-color: var(--input-background-color);
color: var(--text-color);
border: 1px solid black;
border-right-color: var(--input-border-light);
border-bottom-color: var(--input-border-light);
container-type: inline-size;
}
.wide {
display: flex;
flex-direction: column;
justify-content: space-around;
}
.marquee > * {
-webkit-animation: marquee 10s linear infinite both alternate;
animation: marquee 10s linear infinite both alternate;
}
@-webkit-keyframes marquee {
to {
transform: translateX(min(100cqw - 100%, 0px));
}
}
@keyframes marquee {
to {
transform: translateX(min(100cqw - 100%, 0px));
}
}
#nav {
padding: 5px;
background: linear-gradient(0deg, rgba(3,7,11,1) 0%, rgba(14,27,43,1) 4%, rgba(41,55,74,1) 6%, rgba(18,35,56,1) 94%, rgba(40,68,104,1) 96%, rgba(168,182,200,1) 100%);
}
#nav > div {
border-right: 1px solid black;
}
thead {
background: #0F1D2F linear-gradient(0deg, rgba(15, 29, 47, 1) 0%, rgba(15, 29, 47, 1) 50%, rgba(7, 14, 23, 1) 100%);
}
th {
font-weight: bold;
padding: 2px 2px 2px 14px;
border: solid #1c2c1a;
border-width: 0 1px 0 0;
cursor: pointer;
}
/* show and hide action buttons on hover */
tbody tr td button {
display: none;
}
tbody tr:hover td button {
display: inline-block;
}
/* fixed width for action buttons in queue so it doesn't change size when hovering */
tbody tr td:last-of-type {
min-width: 6em;
}
tbody td.actions {
white-space: nowrap;
}
#queue {
border-bottom: 4px ridge #3a506b;
}
/* make arrow for currently playing song look nice */
#queue table tr.playing td:first-of-type::before {
content: '\2BC8'; /* ⯈ Black Medium Right-Pointing Triangle Centred */
}
#queue table tr td:first-of-type {
text-align: right;
padding-right: 0.5em;
}
/* align times */
#queue table tr td:nth-last-of-type(2) {
text-align: right;
}
table tr:nth-child(odd) td {
background-color: #1e1f1a;
}
table tr:nth-child(even) td {
background-color: #171812;
}
#queue table tr:nth-child(odd).playing td,
#queue table tr:nth-child(even).playing td {
background-color: #490b00;
}
table tr:hover td {
background-color: #354158;
}
#tabs {
display: flex;
}
#tabs a {
width: 50%;
padding: 3pt;
display: inline-block;
text-align: center;
background-color: var(--input-background-color);
color: var(--text-color);
border: 1px solid var(--input-border-light);
border-top-left-radius: 5pt;
border-top-right-radius: 5pt;
}
#tabs a.active {
background-color: #1a1a1a;
color: var(--text-color);
border-bottom: none;
}
#browser {
background-color: #171812;
border-right: 4px ridge #3a506b;
}
#control-playlist-list {
font-size: 12pt;
width: 100%;
background-color: var(--input-background-color);
color: var(--text-color);
border: 1px solid black;
border-right-color: var(--input-border-light);
border-bottom-color: var(--input-border-light);
scrollbar-color: #490b00 #09101d; /* only in firefox: https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color */
}
footer svg {
color: var(--text-color);
width: 12pt;
height: 12pt;
}
/*dialog {*/
/* position: fixed;*/
/* left: 50%;*/
/* top: 50%;*/
/* transform: translate(-50%, -50%);*/
/*}*/
dialog {
background-color: var(--background-color);
color: var(--text-color);
}
dialog .close {
position: absolute;
top: 1pt;
right: 1pt;
}
#control-search-pattern {
margin: 1em;
}
#search:first-child {
display: flex;
justify-content: space-between;
}

150
static/css/treeview.css Normal file
View file

@ -0,0 +1,150 @@
/* https://codepen.io/defims/pen/DBaVXM */
ul {
float: left;
clear: left;
margin-left: .25em;
padding: 0;
}
ul:before {
content:"";
position: absolute;
z-index: 1;
top:.25em;
right:auto;
bottom:0;
left: 1.75em;
margin: auto;
/* border-right: dotted white .1em; */
width: 0;
height: auto;
}
ul:after {
content: "-";
position: absolute;
z-index: 3;
top: 0;
left: -.5em;
margin-left: .65em;
margin-top: .15em;
padding: 0;
width: .8em;
height: .8em;
text-align: center;
line-height: .7em;
font-size: 1em;
}
ul > li {
display: block;
position: relative;
float: left;
clear: both;
right:auto;
padding-left: 1em;
width:auto;
text-align: center;
color:white;
}
ul > li > input[type=checkbox] {
display:block;
position: absolute;
float: left;
z-index: 4;
margin: 0 0 0 -1em;
padding: 0;
width:1em;
height: 2em;
font-size: 1em;
opacity: 0;
cursor: pointer;
}
ul > li > input[type=checkbox]:not(:checked)~ul:before {
display: none;
}
ul > li > input[type=checkbox]:not(:checked)~ul:after {
content: "+"
}
ul > li > input[type=checkbox]:not(:checked)~ul * {
display: none;
}
ul > li > span {
display: block;
position: relative;
float: left;
z-index: 3;
margin-left: .25em;
padding-left: .25em;
}
ul > li > span:after {
content: "";
display: block;
position: absolute;
left:-1em;
top:0;
bottom:0;
margin: auto .25em auto .25em;
/* border-top: dotted white .1em; */
width: .75em;
height: 0;
}
ul > li:last-child:before {
content: ""; display: block; position: absolute; z-index: 2;
top:1em; left:0; bottom:-.25em;
width:.75em; height:auto;
}
#tree {
position: relative; font-family: "Georgia";
}
#tree:before {
left:.5em;
}
#tree:after {
display: none;
}
/* decoration */
ul, ul > li:last-child:before {
background: transparent;
}
ul > li {
background: transparent;
}
ul:after {
background: linear-gradient(135deg, rgba(255,255,255,1), rgba(195,186,170,1));
color: black;
border:solid gray 1px;
border-radius: .1em;
}
ul > li > span {
border-radius: .25em;
color: white;
}
ul > li > input[type=checkbox]~span:before {
content:""; display: inline-block;
margin: 0 .25em 0 0;
width:1em; height: 1em; ;line-height: 1em;
content: '\1F4C1';
background-repeat:no-repeat;
background-size:contain;
}
ul > li > input[type=checkbox]:checked~span:before {
content: '\1F4C2';
}

View file

@ -1,11 +1,38 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/html">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="application-name" content="Sanic">
<!-- seo data -->
<meta name="description" lang="en" content="chaos music control inspired by relaxx player">
<meta name="keywords" lang="en" content="music, playlist, mpd, ui">
<meta name="author" content="XenGi">
<link rel="canonical" href="https://gitlab.com/XenGi/sanic">
<!-- social media -->
<meta property="og:title" content="Sanic">
<meta property="og:description" content="chaos music control inspired by relaxx player">
<meta property="og:url" content="https://gitlab.com/XenGi/sanic">
<meta property="og:image" content="https://gitlab.com/XenGi/sanic/-/raw/main/static/img/sanic-logo.webp?ref_type=heads&inline=false">
<meta property="og:image:alt" content="Crappy picture of sonic the hedgehog">
<meta name="twitter:title" content="Sanic">
<meta name="twitter:description" content="chaos music control inspired by relaxx player">
<meta name="twitter:url" content="https://gitlab.com/XenGi/sanic">
<meta name="twitter:image:src" content="https://gitlab.com/XenGi/sanic/-/raw/main/static/img/sanic-logo.webp?ref_type=heads&inline=false">
<meta name="twitter:image:alt" content="Crappy picture of sonic the hedgehog">
<!-- icons and color -->
<meta name="theme-color" content="#000000">
<meta name="theme-scheme" content="dark">
<link rel="shortcut icon" href="/favicon.ico">
<title>Sanic</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="treeview.css">
<link rel="icon" href="favicon.ico" sizes="16x16 32x32 48x48 64x64" type="image/png">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/treeview.css">
</head>
<body>
@ -17,7 +44,7 @@
<input type="text" id="control-playlist-name" name="playlist-name" autofocus>
<button>Save</button>
</form>
</dialog>
</dialog><!--/#save-playlist-->
<main>
<div id="nav">
@ -36,7 +63,7 @@
<div class="spaced">
<label for="control-progress"></label>
<input type="range" id="control-progress" name="progress" min="0" step="1" />
</div>
</div><!--/.spaced-->
</div>
<div>
<div class="spaced">
@ -63,20 +90,20 @@
<!--<input type="text" id="control-track" name="track" disabled="disabled" />-->
<div class="marquee" id="control-track" data-songid="">
<span></span>
</div>
</div><!--/#control-track /.marquee-->
</div>
<div>
<label for="control-time">Time:</label>
<input type="text" id="control-time" name="time" value="00:00:00/00:00:00" disabled="disabled" />
</div>
</div>
</div><!--/.wide-->
<div id="sanic-logo">
<div><!-- TODO: try to remove this div -->
<img alt="sanic logo" src="img/sanic-logo.webp" />
Sanic &copy; 2023
</div>
</div><!--/#sanic-logo-->
</div>
</div><!--/#nav-->
<div id="queue">
<table>
<thead>
@ -179,7 +206,7 @@
</li>
</ul>
</li>
</ul>
</ul><!--/#tree-->
</div>
<div>
actions

358
static/js/index.js Normal file
View file

@ -0,0 +1,358 @@
// Configuration
const API_URL = `${document.location.protocol}//${document.location.host}/api`;
const VOLUME_STEP = 5;
// Get control elements
const dialog_save_playlist = document.getElementById("save-playlist");
const control_playlist_name = document.getElementById("control-playlist-name");
const dialog_save_playlist_submit = document.querySelector("#save-playlist form button");
const dialog_save_playlist_close = document.querySelector("#save-playlist .close");
const connection_state = document.getElementById("connection-state");
const control_update_db = document.getElementById("control-update-db");
const control_previous = document.getElementById("control-previous");
const control_play_pause = document.getElementById("control-play-pause");
const control_stop = document.getElementById("control-stop");
const control_next = document.getElementById("control-next");
const control_progress = document.getElementById("control-progress");
const control_repeat = document.getElementById("control-repeat");
const control_shuffle = document.getElementById("control-shuffle");
const control_xfade = document.getElementById("control-xfade");
const control_xfade_minus = document.getElementById("control-xfade-minus");
const control_xfade_plus = document.getElementById("control-xfade-plus");
const control_volume = document.getElementById("control-volume");
const control_volume_up = document.getElementById("control-volume-up");
const control_volume_down = document.getElementById("control-volume-down");
const queue_table = document.querySelector("#queue tbody");
const control_track = document.getElementById("control-track");
const control_time = document.getElementById("control-time");
const tabs = document.getElementById("tabs");
const tab_browser = document.getElementById("tab-browser");
const tab_search = document.getElementById("tab-search");
const tab_playlists = document.getElementById("tab-playlists");
const control_playlist_list = document.getElementById("control-playlist-list");
const control_refresh_playlists = document.getElementById("control-refresh-playlists");
const control_replace_playlist = document.getElementById("control-replace-playlist");
const control_attach_playlist = document.getElementById("control-attach-playlist");
const control_save_playlist = document.getElementById("control-save-playlist");
const control_delete_playlist = document.getElementById("control-delete-playlist");
const result_table = document.querySelector("#result tbody");
const control_search_pattern = document.getElementById("control-search-pattern");
const control_search_submit = document.getElementById("control-search-submit");
// Utility functions
secondsToTrackTime = (t) => {
const hours = Math.floor(t / 3600);
const minutes = Math.floor((t - hours * 3600) / 60);
const seconds = Math.floor(t - hours * 3600 - minutes * 60);
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
removeTrackFromQueue = (event) => {
const song_id = event.target.parentElement.parentElement.dataset.song_id;
console.log(`DEBUG: remove song id ${song_id} from queue`);
fetch(`${API_URL}/queue/${song_id}/delete`).then(r => {
console.log(r.text());
});
}
moveTrackInQueue = (event, direction) => {
const song_id = event.target.parentElement.parentElement.dataset.song_id;
// TODO: figure out position in queue by counting HTML elements?
const position = parseInt(event.target.parentElement.parentElement.firstChild.innerText);
console.log(`DEBUG: move song ${song_id} down in queue to position ${position + direction}`);
fetch(`${API_URL}/queue/${song_id}/move/${position + direction}`).then(r => {
console.log(r.text());
});
}
refreshPlaylists = () => {
console.log("Refreshing playlists from MPD server")
fetch(`${API_URL}/playlists`).then(async r => {
if (r.status === 200) {
const playlists = await r.json();
control_playlist_list.options.length = 0; // clear playlists
playlists.forEach(p => {
const option = document.createElement("option")
option.innerText = p["playlist"];
option.value = p["playlist"];
option.addEventListener("click", () => {
fetch(`${API_URL}/playlists/${p["playlist"]}`).then(async r => {
if (r.status === 200) fillResultTable(await r.json());
})
});
control_playlist_list.appendChild(option)
});
}
});
}
fillResultTable = (songs) => {
result_table.innerHTML = "";
songs.forEach(song => {
const tr = document.createElement("tr");
const artist = document.createElement("td");
artist.innerText = song["Artist"];
const title = document.createElement("td");
title.innerText = song["Title"];
const time = document.createElement("td");
time.innerText = secondsToTrackTime(parseInt(song["Time"]))
tr.appendChild(artist);
tr.appendChild(title);
tr.appendChild(document.createElement("td")); // album
tr.appendChild(document.createElement("td")); // genre
tr.appendChild(time);
result_table.appendChild(tr);
});
}
// UI controls
tab_browser.addEventListener("click", () => {
if (!tab_browser.classList.contains("active")) {
tab_browser.classList.add("active");
tab_search.classList.remove("active")
tab_playlists.classList.remove("active")
document.getElementById("file-browser").style.display = "block";
document.getElementById("search").style.display = "none";
document.getElementById("playlist-browser").style.display = "none";
}
});
tab_search.addEventListener("click", () => {
if (!tab_search.classList.contains("active")) {
tab_browser.classList.remove("active");
tab_search.classList.add("active")
tab_playlists.classList.remove("active")
document.getElementById("file-browser").style.display = "none";
document.getElementById("search").style.display = "block";
document.getElementById("playlist-browser").style.display = "none";
}
});
tab_playlists.addEventListener("click", () => {
refreshPlaylists();
if (!tab_playlists.classList.contains("active")) {
tab_browser.classList.remove("active");
tab_search.classList.remove("active")
tab_playlists.classList.add("active")
document.getElementById("file-browser").style.display = "none";
document.getElementById("search").style.display = "none";
document.getElementById("playlist-browser").style.display = "block";
}
});
// Show "Save playlist" modal
control_save_playlist.addEventListener("click", () => {
dialog_save_playlist.showModal()
});
// Close "Save playlist" modal
dialog_save_playlist_close.addEventListener("click", () => {
dialog_save_playlist.close()
});
// Add API calls to controls
control_search_submit.addEventListener("click", event => {
event.preventDefault()
fetch(`${API_URL}/database/${control_search_pattern.value}`).then(async r => {
if (r.status === 200) {
fillResultTable([...new Set(await r.json())]);
} else {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
})
});
control_refresh_playlists.addEventListener("click", () => {
refreshPlaylists();
});
control_replace_playlist.addEventListener("click", () => {
fetch(`${API_URL}/queue/replace/${control_playlist_list.value}`).then(async r => {
if (r.status !== 200) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_attach_playlist.addEventListener("click", () => {
fetch(`${API_URL}/queue/attach/${control_playlist_list.value}`).then(async r => {
if (r.status !== 200) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
// Save current queue as new playlist and refresh playlist list
dialog_save_playlist_submit.addEventListener("click", () => {
fetch(`${API_URL}/playlists/${control_playlist_name.value}`, {method: "POST"}).then(async r => {
if (r.status === 201) {
console.log(`Playlist "${control_playlist_name.value}" saved`)
refreshPlaylists()
} else {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_delete_playlist.addEventListener("click", () => {
const playlist_name = control_playlist_list.value;
fetch(`${API_URL}/playlists/${control_playlist_list.value}`, {method: "DELETE"}).then(r => {
if (r.status === 204) {
console.log(`Playlist "${playlist_name}" successfully deleted.`);
refreshPlaylists();
} else {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_update_db.addEventListener("click", (event) => {
console.log("Issuing database update");
fetch(`${API_URL}/update_db`).then(async r => {
if (r.status === 200) {
// const idText = await r.text();
console.log(await r.text());
// event.target.dataset.jobid = idText.split(" ").pop();
// event.target.disabled = true;
} else {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_previous.addEventListener("click", () => {
fetch(`${API_URL}/previous_track`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_play_pause.addEventListener("click", event => {
if (event.target.innerHTML === "⏸︎") { // Resume playback
fetch(`${API_URL}/pause`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
} else { // Pause playback
fetch(`${API_URL}/play`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
}
});
control_stop.addEventListener("click", () => {
fetch(`${API_URL}/stop`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_next.addEventListener("click", () => {
fetch(`${API_URL}/next_track`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_progress.addEventListener("change", event => {
fetch(`${API_URL}/seek/${event.target.value}`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_progress.addEventListener("input", event => {
control_time.value = `${secondsToTrackTime(event.target.value)}/${secondsToTrackTime(event.target.max)}`;
});
control_repeat.addEventListener("click", event => {
if (event.target.dataset.state === "on") { // TODO: check is never true
event.target.innerHTML = "&#x1F518; repeat";
event.target.dataset.state = "off";
} else {
event.target.innerHTML = "&#x1F534; repeat";
event.target.dataset.state = "on";
}
fetch(`${API_URL}/repeat`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_shuffle.addEventListener("click", event => {
if (event.target.dataset.state === "on") { // TODO: check is never true
event.target.innerHTML = "&#x1F518; shuffle";
event.target.dataset.state = "off";
} else {
event.target.innerHTML = "&#x1F534; shuffle";
event.target.dataset.state = "on";
}
fetch(`${API_URL}/random`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_xfade_minus.addEventListener("click", () => {
// TODO: not yet implemented
fetch(`${API_URL}/xfade`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_xfade_plus.addEventListener("click", () => {
// TODO: not yet implemented
fetch(`${API_URL}/xfade`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});
control_volume_up.addEventListener("click", () => {
const volume = Math.min(parseInt(control_volume.value) + VOLUME_STEP, 100);
fetch(`${API_URL}/volume/${volume}`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
control_volume.value = volume;
});
control_volume_down.addEventListener("click", () => {
const volume = Math.max(parseInt(control_volume.value) - VOLUME_STEP, 0);
fetch(`${API_URL}/volume/${volume}`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
control_volume.value = volume;
});
control_volume.addEventListener("change", event => {
fetch(`${API_URL}/volume/${event.target.value}`).then(async r => {
if (r.status >= 400) {
console.error(`API returned ${r.status}: ${r.statusText}`);
}
});
});

178
static/js/sse.js Normal file
View file

@ -0,0 +1,178 @@
// Server-Sent-Events
if (typeof (EventSource) !== "undefined") {
const sse = new EventSource("/sse");
sse.addEventListener("status", handleStatus);
sse.addEventListener("currentsong", handleCurrentSong);
sse.addEventListener("queue", handleQueue);
sse.onmessage = (event) => {
console.log("sse message: " + event.data);
};
sse.onerror = (err) => {
console.error("EventSource failed:", err);
connection_state.innerHTML = "&#x274C; Disconnected"; // ❌ Cross Mark
};
sse.onopen = () => {
console.log("EventSource connected");
connection_state.innerHTML = "&#x2705; Connected"; // ✅ Check Mark Button
};
} else {
console.error("Sorry, your browser does not support server-sent events...");
}
function handleStatus(event) {
const status = JSON.parse(event.data);
// print error if present
if ("error" in status) {
console.error(status.error);
}
// update "Update DB" button
if ("updating_db" in status) {
control_update_db.disabled = true;
} else {
if (control_update_db.disabled) {
console.log("Database update done.")
}
control_update_db.disabled = false;
}
// update play/pause button
// TODO: only update DOM if necessary
if ("state" in status && status.state !== "play") {
control_play_pause.innerHTML = "&#x23F5;&#xFE0E;"; // Play
} else {
control_play_pause.innerHTML = "&#x23F8;&#xFE0E;"; // Pause
}
if ("songid" in status) {
control_track.dataset.songid = status.songid;
}
// update playback time
if ("time" in status) {
const [elapsed, duration] = status.time.split(":", 2)
control_progress.value = elapsed;
control_progress.max = duration;
// triggers the update of control_time element
const e = new Event("input");
control_progress.dispatchEvent(e);
}
// update repeat state
if ("repeat" in status) {
if (status.repeat === "1") {
control_repeat.innerHTML = "&#x1F534; repeat"; // 🔴 Red Circle
control_repeat.dataset.state = "on";
} else {
control_repeat.innerHTML = "&#x1F518; repeat"; // 🔘 Radio Button
control_repeat.dataset.state = "off";
}
}
// update shuffle state
if ("random" in status) {
if (status.random === "1") {
control_shuffle.innerHTML = "&#x1F534; shuffle"; // 🔴 Red Circle
control_shuffle.dataset.state = "on";
} else {
control_shuffle.innerHTML = "&#x1F518; shuffle"; // 🔘 Radio Button
control_shuffle.dataset.state = "off";
}
}
// update crossfade state
if ("xfade" in status) {
control_xfade.value = status.xfade;
}
// update volume
if ("volume" in status) {
control_volume.value = status.volume;
}
}
function handleCurrentSong(event) {
const current_song = JSON.parse(event.data);
let track;
if ("Artist" in current_song && "Title" in current_song) {
track = `${current_song.Artist} - ${current_song.Title}`
} else {
track = current_song.file;
}
// Only replace if necessary to not interrupt the animation
if (control_track.innerHTML !== `<span>${track}</span>`) {
control_track.innerHTML = `<span>${track}</span>`;
}
}
function handleQueue(event) {
const queue = JSON.parse(event.data);
console.log(queue);
const tbody = document.createElement("tbody");
queue.forEach(song => {
const tr = document.createElement("tr");
tr.dataset.song_id = song.Id;
if (control_track.dataset.songid === song.Id) {
tr.classList.add("playing");
}
const pos = document.createElement("td");
pos.innerText = song.Pos;
const artist = document.createElement("td");
if ("Artist" in song) {
artist.innerText = song.Artist;
}
const track = document.createElement("td");
if ("Title" in song) {
track.innerText = song.Title;
} else {
track.innerText = song.file;
}
const album = document.createElement("td");
// TODO: Do songs have album info attached to them?
album.innerText = "";
const length = document.createElement("td");
length.innerText = secondsToTrackTime(song.duration);
const actions = document.createElement("td");
const moveUp = document.createElement("button");
moveUp.classList.add("borderless");
if (parseInt(song.Pos) !== 0) {
moveUp.innerHTML = "&#x1F53A;"; // 🔺 Red Triangle Pointed Down
moveUp.addEventListener("click", event => { moveTrackInQueue(event, -1) });
} else {
moveUp.innerHTML = "&emsp;";
}
const moveDown = document.createElement("button");
moveDown.classList.add("borderless");
if (parseInt(song.Pos) !== queue.length - 1) {
moveDown.innerHTML = "&#x1F53B;"; // 🔻 Red Triangle Pointed Up
moveDown.addEventListener("click", event => {moveTrackInQueue(event, 1)});
} else {
moveDown.innerHTML = "&emsp;";
}
const remove = document.createElement("button");
remove.classList.add("borderless");
remove.innerHTML = "&#x274C;"; // ❌ Cross mark
remove.addEventListener("click", removeTrackFromQueue);
actions.appendChild(moveUp);
actions.appendChild(moveDown);
actions.appendChild(remove);
tr.appendChild(pos);
tr.appendChild(artist);
tr.appendChild(track);
tr.appendChild(album);
tr.appendChild(length);
tr.appendChild(actions);
tbody.appendChild(tr);
});
const currentQueue = document.querySelector("#queue tbody")
// only update queue if necessary to not interrupt user interaction
if (currentQueue.innerHTML !== tbody.innerHTML) {
console.log("Updating queue")
currentQueue.outerHTML = tbody.outerHTML;
}
}