(Proper) Initial commit
This commit is contained in:
parent
48c0059860
commit
32796e4026
19 changed files with 6094 additions and 97 deletions
BIN
frontend/assets/cover-default.png
Normal file
BIN
frontend/assets/cover-default.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
45
frontend/index.html
Normal file
45
frontend/index.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Queue Manager</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="connectivity-status" class="hidden">
|
||||
<p>
|
||||
<strong>Connection Status:</strong>
|
||||
<span id="status">Offline</span>
|
||||
</p>
|
||||
</div>
|
||||
<main>
|
||||
<h1>Queue Manager</h1>
|
||||
<div id="queue-form">
|
||||
<label for="song-url">Song URL</label>
|
||||
<input type="url" id="song-url" placeholder="https://open.spotify.com/track/4PTG3Z6ehGkBFwjybzWkR8?si=7c5064fbbdde4bc2">
|
||||
<button id="add-song">Add to queue</button>
|
||||
</div>
|
||||
<h2>Currently Processing</h2>
|
||||
<div id="currently-processing"></div>
|
||||
<div id="queue-list">
|
||||
<h2>Queue</h2>
|
||||
<button id="refresh-queue">Refresh Queue</button>
|
||||
<button id="clear-queue">Clear Queue</button>
|
||||
<ol id="queue"></ol>
|
||||
</div>
|
||||
<div id="queue-history">
|
||||
<h2>History</h2>
|
||||
<ul id="history"></ul>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<div id="api-url-form">
|
||||
<label for="api-url">API URL</label>
|
||||
<input type="url" id="api-url" placeholder="http://localhost:3000">
|
||||
<button type="submit" id="set-api">Connect</button>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
331
frontend/script.js
Normal file
331
frontend/script.js
Normal file
|
@ -0,0 +1,331 @@
|
|||
let apiBaseUrl = 'http://localhost:3000/api/v1';
|
||||
const processingList = [];
|
||||
|
||||
async function checkConnectivity() {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/ping`);
|
||||
if (!response.ok) {
|
||||
document.getElementById('connectivity-status').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
document.getElementById('connectivity-status').classList.add('hidden');
|
||||
} catch {
|
||||
document.getElementById('connectivity-status').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setApiUrl() {
|
||||
const url = document.getElementById('api-url');
|
||||
|
||||
if (url === null || url.value === '') {
|
||||
console.log('Using default API URL');
|
||||
apiBaseUrl = 'http://127.0.0.1:3000/api/v1';
|
||||
} else {
|
||||
apiBaseUrl = url.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAdd(url) {
|
||||
return fetch(`${apiBaseUrl}/queue`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({song: url})
|
||||
});
|
||||
}
|
||||
|
||||
async function addToQueue() {
|
||||
const song = document.getElementById('song-url');
|
||||
if (!song || !(song instanceof HTMLInputElement)) {
|
||||
alert('Failed to find song URL input');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = song.value;
|
||||
|
||||
if (!url) {
|
||||
alert('Please enter a song URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await sendAdd(url);
|
||||
if (response.ok) {
|
||||
loadQueue();
|
||||
song.value = '';
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
alert(`Failed to add song to queue: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFromQueue(item) {
|
||||
const song = encodeURIComponent(item.song.url);
|
||||
const response = await fetch(`${apiBaseUrl}/queue/${song}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadQueue();
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
alert(`Failed to delete song from queue: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function retry(item) {
|
||||
const song = encodeURIComponent(item.song.url);
|
||||
const response = await fetch(`${apiBaseUrl}/queue/retry/${song}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadQueue();
|
||||
loadHistory();
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
alert(`Failed to retry song: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearQueue() {
|
||||
// Show a confirmation dialog
|
||||
if (!confirm('Are you sure you want to clear the queue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/queue`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadQueue();
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
alert(`Failed to clear queue: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
const response = await fetch(`${apiBaseUrl}/queue`);
|
||||
const queue = await response.json();
|
||||
|
||||
const queueList = document.getElementById('queue');
|
||||
if (!queueList) {
|
||||
alert('Failed to find queue list');
|
||||
return;
|
||||
}
|
||||
|
||||
queueList.innerHTML = '';
|
||||
queue.forEach(item => {
|
||||
const element = constructQueueItem(item);
|
||||
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.classList.add('delete');
|
||||
deleteButton.textContent = 'x';
|
||||
deleteButton.addEventListener('click', () => deleteFromQueue(item));
|
||||
element.prepend(deleteButton);
|
||||
|
||||
queueList.appendChild(element);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProcessing() {
|
||||
const response = await fetch(`${apiBaseUrl}/queue/processing`);
|
||||
const status = await response.json();
|
||||
|
||||
const processingDiv = document.getElementById('currently-processing');
|
||||
if (!processingDiv) {
|
||||
alert('Failed to find processing status element');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.length === 0) {
|
||||
processingDiv.textContent = 'Nothing processing';
|
||||
}
|
||||
|
||||
status.forEach(item => {
|
||||
// Add new items if they are not already in the list
|
||||
if (!processingList.some(element => element.id === item.item.id)) {
|
||||
const listItem = constructQueueItem(item.item);
|
||||
|
||||
const details = document.createElement('details');
|
||||
details.id = item.item.id;
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.innerHTML = listItem.innerHTML
|
||||
details.appendChild(summary);
|
||||
|
||||
const trackCount = document.createElement('p');
|
||||
trackCount.innerText = item.item.song.trackCount + ' tracks';
|
||||
details.appendChild(trackCount);
|
||||
|
||||
const progress = document.createElement('p');
|
||||
progress.classList.add('progress');
|
||||
progress.textContent = item.status;
|
||||
details.appendChild(progress);
|
||||
|
||||
processingDiv.appendChild(details);
|
||||
processingList.push(details);
|
||||
} else {
|
||||
// Update the progress of existing items
|
||||
const element = processingList.find(element => element.id === item.item.id);
|
||||
const progress = element.querySelector('.progress');
|
||||
progress.innerText = item.status;
|
||||
}
|
||||
});
|
||||
|
||||
// Remove items that are no longer processing
|
||||
const toRemove = processingList.filter(element => !status.some(item => item.item.id === element.id));
|
||||
toRemove.forEach(element => {
|
||||
console.log('Removing', element);
|
||||
processingList.splice(processingList.indexOf(element), 1);
|
||||
element.remove();
|
||||
|
||||
// Add the item to the history
|
||||
const historyList = document.getElementById('history');
|
||||
if (!historyList) {
|
||||
alert('Failed to find history list');
|
||||
return;
|
||||
}
|
||||
historyList.prepend(element.querySelector('summary'));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const response = await fetch(`${apiBaseUrl}/queue/history`);
|
||||
const history = await response.json();
|
||||
|
||||
const historyList = document.getElementById('history');
|
||||
if (!historyList) {
|
||||
alert('Failed to find history list');
|
||||
return;
|
||||
}
|
||||
|
||||
historyList.innerHTML = '';
|
||||
history.reverse().forEach(item => {
|
||||
const element = constructQueueItem(item);
|
||||
|
||||
if (!item.result.success) {
|
||||
const retryButton = document.createElement('button');
|
||||
retryButton.classList.add('retry');
|
||||
retryButton.textContent = 'Retry';
|
||||
retryButton.addEventListener('click', () => retry(item));
|
||||
element.appendChild(retryButton);
|
||||
}
|
||||
|
||||
historyList.appendChild(element);
|
||||
})
|
||||
}
|
||||
|
||||
function getSourceIcon(item) {
|
||||
const sourceToIcon = new Map([
|
||||
['spotify', 's'],
|
||||
['qobuz', 'q'],
|
||||
['unknown', '?']
|
||||
]);
|
||||
|
||||
return sourceToIcon.get(item.song.source) ?? '?';
|
||||
}
|
||||
|
||||
function getState(item) {
|
||||
const state = document.createElement('span');
|
||||
state.classList.add('state');
|
||||
if (!item.result) {
|
||||
state.textContent = '⌛';
|
||||
state.title = 'Processing';
|
||||
} else {
|
||||
state.textContent = item.result.success ? '✓' : '✗';
|
||||
state.title = item.result.success ? 'Processed' : 'Failed: ' + item.result.error;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function getCoverImage(item) {
|
||||
const img = document.createElement('img');
|
||||
img.src = item.song.cover || "./assets/cover-default.png";
|
||||
img.alt = `${item.song.title} - ${item.song.artist}`;
|
||||
img.classList.add('cover-art');
|
||||
return img;
|
||||
}
|
||||
|
||||
function constructQueueItem(item) {
|
||||
const element = document.createElement('li');
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.classList.add('title');
|
||||
title.textContent = `${item.song.title} - ${item.song.artist}`;
|
||||
element.appendChild(title);
|
||||
|
||||
const coverImage = getCoverImage(item);
|
||||
element.prepend(coverImage);
|
||||
|
||||
const source = document.createElement('span');
|
||||
source.classList.add('source');
|
||||
source.textContent = getSourceIcon(item);
|
||||
const link = document.createElement('a');
|
||||
link.href = item.song.url;
|
||||
link.appendChild(source);
|
||||
element.appendChild(link);
|
||||
|
||||
element.appendChild(getState(item));
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const addSongButton = document.getElementById('add-song');
|
||||
if (!addSongButton) {
|
||||
alert('Failed to find add song button');
|
||||
return;
|
||||
}
|
||||
addSongButton.addEventListener('click', addToQueue);
|
||||
const addSongInput = document.getElementById('song-url');
|
||||
if (!addSongInput) {
|
||||
alert('Failed to find song URL input');
|
||||
return;
|
||||
}
|
||||
addSongInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
addToQueue();
|
||||
}
|
||||
});
|
||||
addSongInput.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
addSongInput.value = event.dataTransfer.getData('text');
|
||||
addToQueue();
|
||||
});
|
||||
|
||||
const refreshQueueButton = document.getElementById('refresh-queue');
|
||||
if (!refreshQueueButton) {
|
||||
alert('Failed to find refresh queue button');
|
||||
return;
|
||||
}
|
||||
refreshQueueButton.addEventListener('click', loadQueue);
|
||||
|
||||
const clearQueueButton = document.getElementById('clear-queue');
|
||||
if (!clearQueueButton) {
|
||||
alert('Failed to find clear queue button');
|
||||
return;
|
||||
}
|
||||
clearQueueButton.addEventListener('click', clearQueue);
|
||||
|
||||
const apiUrlButton = document.getElementById('set-api');
|
||||
if (!apiUrlButton) {
|
||||
alert('Failed to find set API URL button');
|
||||
return;
|
||||
}
|
||||
apiUrlButton.addEventListener('click', setApiUrl);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setApiUrl();
|
||||
loadQueue();
|
||||
loadHistory();
|
||||
checkConnectivity();
|
||||
|
||||
setInterval(checkConnectivity, 5000);
|
||||
setInterval(loadProcessing, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
setup();
|
96
frontend/style.css
Normal file
96
frontend/style.css
Normal file
|
@ -0,0 +1,96 @@
|
|||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: white;
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid #ccc;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: #ffffff;
|
||||
padding: 20px;
|
||||
margin: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
width: 80%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#queue-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#queue-form input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#queue-form button {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
#queue-list, #queue-history {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#queue-list ol, #queue-history ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
#queue-list li, #queue-history li {
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#connectivity-status {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
background: red;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cover-art {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.delete, .source, .state, .retry {
|
||||
float: right;
|
||||
margin-left: 10px;
|
||||
margin-right: 0;
|
||||
}
|
Reference in a new issue