Initial import of rkt-web-player
This commit is contained in:
+424
@@ -0,0 +1,424 @@
|
||||
const elements = {
|
||||
renderer: document.querySelector("#renderer"),
|
||||
discover: document.querySelector("#discover"),
|
||||
previous: document.querySelector("#previous"),
|
||||
play: document.querySelector("#play"),
|
||||
stop: document.querySelector("#stop"),
|
||||
next: document.querySelector("#next"),
|
||||
seek: document.querySelector("#seek"),
|
||||
position: document.querySelector("#position"),
|
||||
duration: document.querySelector("#duration"),
|
||||
repeat: document.querySelector("#repeat"),
|
||||
volume: document.querySelector("#volume"),
|
||||
volumeValue: document.querySelector("#volume-value"),
|
||||
library: document.querySelector("#library"),
|
||||
libraryUp: document.querySelector("#library-up"),
|
||||
breadcrumb: document.querySelector("#breadcrumb"),
|
||||
libraryEmpty: document.querySelector("#library-empty"),
|
||||
libraryEntries: document.querySelector("#library-entries"),
|
||||
title: document.querySelector("#now-title"),
|
||||
meta: document.querySelector("#now-meta"),
|
||||
tabs: document.querySelector("#tabs"),
|
||||
tabAdd: document.querySelector("#tab-add"),
|
||||
count: document.querySelector("#track-count"),
|
||||
playlistClear: document.querySelector("#playlist-clear"),
|
||||
playlistEmpty: document.querySelector("#playlist-empty"),
|
||||
playlist: document.querySelector("#playlist"),
|
||||
bits: document.querySelector("#audio-bits"),
|
||||
rate: document.querySelector("#audio-rate"),
|
||||
channels: document.querySelector("#audio-channels"),
|
||||
format: document.querySelector("#audio-format"),
|
||||
source: document.querySelector("#audio-source"),
|
||||
status: document.querySelector("#status"),
|
||||
};
|
||||
|
||||
let state = null;
|
||||
let seekBusy = false;
|
||||
let draggedTrack = null;
|
||||
let commandBusy = false;
|
||||
|
||||
function formatTime(value) {
|
||||
if (!Number.isFinite(value) || value < 0) return "00:00:00";
|
||||
const whole = Math.floor(value);
|
||||
const hours = Math.floor(whole / 3600).toString().padStart(2, "0");
|
||||
const minutes = Math.floor((whole % 3600) / 60).toString().padStart(2, "0");
|
||||
const seconds = (whole % 60).toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
elements.status.textContent = message || "";
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const options = body === undefined
|
||||
? { cache: "no-store" }
|
||||
: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
const response = await fetch(path, options);
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function command(name, data = {}, pendingMessage = "") {
|
||||
if (pendingMessage) setStatus(pendingMessage);
|
||||
commandBusy = true;
|
||||
try {
|
||||
render(await api(`/api/command/${name}`, data));
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
commandBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceSelect(select, items, selectedId, signature) {
|
||||
if (select.dataset.signature !== signature) {
|
||||
select.replaceChildren(...items.map((item) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.id;
|
||||
option.textContent = item.label;
|
||||
return option;
|
||||
}));
|
||||
select.dataset.signature = signature;
|
||||
}
|
||||
select.value = selectedId || "";
|
||||
}
|
||||
|
||||
function renderSelectors(nextState) {
|
||||
const rendererItems = nextState.renderers.map((item) => ({
|
||||
id: item.id,
|
||||
label: `${item.name} · ${item.kind.toUpperCase()}`,
|
||||
}));
|
||||
replaceSelect(
|
||||
elements.renderer,
|
||||
rendererItems,
|
||||
nextState.rendererId,
|
||||
rendererItems.map((item) => item.id).join("|"),
|
||||
);
|
||||
|
||||
const libraryItems = nextState.libraries.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
}));
|
||||
replaceSelect(
|
||||
elements.library,
|
||||
libraryItems,
|
||||
nextState.libraryId,
|
||||
libraryItems.map((item) => `${item.id}:${item.label}`).join("|"),
|
||||
);
|
||||
|
||||
elements.discover.classList.toggle("busy", nextState.discovering);
|
||||
elements.discover.disabled = nextState.discovering;
|
||||
elements.library.disabled = nextState.libraries.length === 0;
|
||||
}
|
||||
|
||||
function entryAction(label, title, handler) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "entry-action";
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
handler();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderBrowser(nextState) {
|
||||
const selectedLibrary = nextState.libraries.find((item) => item.id === nextState.libraryId);
|
||||
const path = [selectedLibrary?.name, ...nextState.browser.path].filter(Boolean);
|
||||
elements.breadcrumb.textContent = path.length ? path.join(" / ") : "Geen bibliotheek";
|
||||
elements.breadcrumb.title = elements.breadcrumb.textContent;
|
||||
elements.libraryUp.disabled = !nextState.browser.canGoUp;
|
||||
elements.libraryEmpty.hidden = nextState.libraries.length > 0;
|
||||
|
||||
const signature = nextState.browser.entries
|
||||
.map((entry) => `${entry.index}:${entry.kind}:${entry.name}`)
|
||||
.join("|");
|
||||
if (elements.libraryEntries.dataset.signature === signature) return;
|
||||
|
||||
const rows = nextState.browser.entries.map((entry) => {
|
||||
const row = document.createElement("li");
|
||||
row.className = "library-entry";
|
||||
row.tabIndex = 0;
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "entry-icon";
|
||||
icon.textContent = entry.kind === "container" ? "▸" : "♪";
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "entry-name";
|
||||
name.textContent = entry.name;
|
||||
name.title = entry.name;
|
||||
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "entry-actions";
|
||||
actions.append(
|
||||
entryAction("▶", `${entry.name} nu afspelen`, () => {
|
||||
command("item-play", { index: entry.index }, `${entry.name} laden…`);
|
||||
}),
|
||||
entryAction("+", `${entry.name} toevoegen`, () => {
|
||||
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
|
||||
}),
|
||||
);
|
||||
|
||||
row.append(icon, name, actions);
|
||||
if (entry.kind === "container") {
|
||||
row.addEventListener("click", () => command("browse", { index: entry.index }));
|
||||
} else {
|
||||
row.addEventListener("dblclick", () => {
|
||||
command("item-play", { index: entry.index }, `${entry.name} laden…`);
|
||||
});
|
||||
}
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
command(
|
||||
entry.kind === "container" ? "browse" : "item-play",
|
||||
{ index: entry.index },
|
||||
entry.kind === "track" ? `${entry.name} laden…` : "",
|
||||
);
|
||||
} else if (event.key === "+") {
|
||||
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
elements.libraryEntries.replaceChildren(...rows);
|
||||
elements.libraryEntries.dataset.signature = signature;
|
||||
}
|
||||
|
||||
function renderTabs(nextState) {
|
||||
const signature = nextState.tabs
|
||||
.map((tab) => `${tab.index}:${tab.name}:${tab.count}`)
|
||||
.join("|");
|
||||
if (elements.tabs.dataset.signature !== signature) {
|
||||
const tabs = nextState.tabs.map((tab) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = "tab";
|
||||
button.type = "button";
|
||||
button.dataset.index = tab.index;
|
||||
button.setAttribute("role", "tab");
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "tab-name";
|
||||
name.textContent = tab.name;
|
||||
const count = document.createElement("span");
|
||||
count.className = "tab-count";
|
||||
count.textContent = tab.count;
|
||||
button.append(name, count);
|
||||
|
||||
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
|
||||
button.addEventListener("dblclick", () => {
|
||||
const newName = window.prompt("Naam van de afspeellijst", tab.name);
|
||||
if (newName !== null) command("tab-rename", { index: tab.index, name: newName });
|
||||
});
|
||||
|
||||
if (nextState.tabs.length > 1) {
|
||||
const remove = document.createElement("span");
|
||||
remove.className = "tab-delete";
|
||||
remove.textContent = "×";
|
||||
remove.title = "Afspeellijst verwijderen";
|
||||
remove.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
command("tab-delete", { index: tab.index });
|
||||
});
|
||||
button.append(remove);
|
||||
}
|
||||
return button;
|
||||
});
|
||||
elements.tabs.replaceChildren(...tabs);
|
||||
elements.tabs.dataset.signature = signature;
|
||||
}
|
||||
|
||||
for (const tab of elements.tabs.children) {
|
||||
const active = Number(tab.dataset.index) === nextState.currentTab;
|
||||
tab.classList.toggle("active", active);
|
||||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlaylist(nextState) {
|
||||
const signature = nextState.tracks
|
||||
.map((track) => `${track.index}:${track.title}:${track.artist}:${track.album}:${track.duration}`)
|
||||
.join("|");
|
||||
if (elements.playlist.dataset.signature !== signature) {
|
||||
const rows = nextState.tracks.map((track) => {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "playlist-row";
|
||||
row.tabIndex = 0;
|
||||
row.draggable = true;
|
||||
row.dataset.index = track.index;
|
||||
|
||||
const number = document.createElement("td");
|
||||
number.className = "track-number number-column";
|
||||
number.textContent = String(track.index + 1);
|
||||
|
||||
const titleCell = document.createElement("td");
|
||||
const title = document.createElement("div");
|
||||
title.className = "track-title";
|
||||
title.textContent = track.title;
|
||||
const artist = document.createElement("div");
|
||||
artist.className = "track-artist";
|
||||
artist.textContent = track.artist || track.source;
|
||||
titleCell.append(title, artist);
|
||||
|
||||
const album = document.createElement("td");
|
||||
album.className = "track-album";
|
||||
album.textContent = track.album;
|
||||
|
||||
const duration = document.createElement("td");
|
||||
duration.className = "track-duration duration-column";
|
||||
duration.textContent = formatTime(track.duration);
|
||||
|
||||
const action = document.createElement("td");
|
||||
action.className = "action-column";
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "row-action";
|
||||
remove.type = "button";
|
||||
remove.textContent = "×";
|
||||
remove.title = `${track.title} verwijderen`;
|
||||
remove.setAttribute("aria-label", `${track.title} verwijderen`);
|
||||
remove.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
command("track-remove", { index: track.index });
|
||||
});
|
||||
action.append(remove);
|
||||
|
||||
row.append(number, titleCell, album, duration, action);
|
||||
row.addEventListener("click", () => command("play", { index: track.index }));
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
command("play", { index: track.index });
|
||||
} else if (event.key === "Delete") {
|
||||
command("track-remove", { index: track.index });
|
||||
}
|
||||
});
|
||||
row.addEventListener("dragstart", () => { draggedTrack = track.index; });
|
||||
row.addEventListener("dragover", (event) => event.preventDefault());
|
||||
row.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
if (Number.isInteger(draggedTrack) && draggedTrack !== track.index) {
|
||||
command("track-move", { from: draggedTrack, to: track.index });
|
||||
}
|
||||
draggedTrack = null;
|
||||
});
|
||||
return row;
|
||||
});
|
||||
elements.playlist.replaceChildren(...rows);
|
||||
elements.playlist.dataset.signature = signature;
|
||||
}
|
||||
|
||||
for (const row of elements.playlist.children) {
|
||||
row.classList.toggle("current", Number(row.dataset.index) === nextState.currentIndex);
|
||||
}
|
||||
|
||||
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
|
||||
elements.count.textContent = `${nextState.tracks.length} ${nextState.tracks.length === 1 ? "track" : "tracks"}`;
|
||||
elements.playlistClear.disabled = nextState.tracks.length === 0;
|
||||
}
|
||||
|
||||
function renderPlayer(nextState) {
|
||||
const current = Number.isInteger(nextState.currentIndex)
|
||||
? nextState.tracks[nextState.currentIndex]
|
||||
: null;
|
||||
elements.title.textContent = current ? current.title : "Nog niets gekozen";
|
||||
elements.meta.textContent = current
|
||||
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
|
||||
: "Voeg een track toe vanuit de bibliotheek";
|
||||
|
||||
const playing = nextState.state === "playing" || nextState.state === "starting";
|
||||
elements.play.textContent = playing ? "Ⅱ" : "▶";
|
||||
elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen");
|
||||
elements.play.disabled = nextState.tracks.length === 0;
|
||||
elements.previous.disabled = nextState.tracks.length === 0;
|
||||
elements.next.disabled = nextState.tracks.length === 0;
|
||||
elements.stop.disabled = nextState.state === "stopped";
|
||||
|
||||
elements.position.textContent = formatTime(nextState.position);
|
||||
elements.duration.textContent = formatTime(nextState.duration);
|
||||
if (!seekBusy) {
|
||||
elements.seek.value = nextState.duration
|
||||
? Math.min(100, (nextState.position / nextState.duration) * 100)
|
||||
: 0;
|
||||
}
|
||||
elements.seek.disabled = !nextState.duration;
|
||||
|
||||
elements.volume.value = nextState.volume;
|
||||
elements.volumeValue.value = `${Math.round(nextState.volume)}%`;
|
||||
elements.repeat.dataset.repeat = nextState.repeat;
|
||||
elements.repeat.classList.toggle("active", nextState.repeat !== "off");
|
||||
const repeatNames = { off: "Herhalen uit", all: "Alles herhalen", one: "Eén track herhalen" };
|
||||
elements.repeat.title = repeatNames[nextState.repeat] || repeatNames.off;
|
||||
|
||||
elements.bits.textContent = nextState.bits ? `${nextState.bits} bit` : "— bit";
|
||||
elements.rate.textContent = nextState.rate ? `${(nextState.rate / 1000).toFixed(1)} kHz` : "— kHz";
|
||||
elements.channels.textContent = nextState.channels ? `${nextState.channels} kanalen` : "— kanalen";
|
||||
elements.format.textContent = nextState.format || "—";
|
||||
elements.source.textContent = nextState.source || "—";
|
||||
}
|
||||
|
||||
function render(nextState) {
|
||||
state = nextState;
|
||||
renderSelectors(nextState);
|
||||
renderBrowser(nextState);
|
||||
renderTabs(nextState);
|
||||
renderPlaylist(nextState);
|
||||
renderPlayer(nextState);
|
||||
setStatus(nextState.error || (nextState.discovering ? "Netwerkspelers zoeken…" : ""));
|
||||
}
|
||||
|
||||
elements.play.addEventListener("click", () => {
|
||||
if (!state) return;
|
||||
const playing = state.state === "playing" || state.state === "starting";
|
||||
command(playing ? "pause" : state.state === "paused" ? "resume" : "play");
|
||||
});
|
||||
elements.previous.addEventListener("click", () => command("previous"));
|
||||
elements.stop.addEventListener("click", () => command("stop"));
|
||||
elements.next.addEventListener("click", () => command("next"));
|
||||
elements.repeat.addEventListener("click", () => {
|
||||
if (!state) return;
|
||||
const next = state.repeat === "off" ? "all" : state.repeat === "all" ? "one" : "off";
|
||||
command("repeat", { mode: next });
|
||||
});
|
||||
elements.renderer.addEventListener("change", () => command("renderer", { id: elements.renderer.value }));
|
||||
elements.discover.addEventListener("click", async () => {
|
||||
try {
|
||||
render(await api("/api/discover", {}));
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
});
|
||||
elements.volume.addEventListener("input", () => {
|
||||
elements.volumeValue.value = `${elements.volume.value}%`;
|
||||
});
|
||||
elements.volume.addEventListener("change", () => command("volume", { value: Number(elements.volume.value) }));
|
||||
elements.seek.addEventListener("pointerdown", () => { seekBusy = true; });
|
||||
elements.seek.addEventListener("change", () => {
|
||||
seekBusy = false;
|
||||
command("seek", { percentage: Number(elements.seek.value) });
|
||||
});
|
||||
elements.library.addEventListener("change", () => command("library", { id: elements.library.value }));
|
||||
elements.libraryUp.addEventListener("click", () => command("up"));
|
||||
elements.tabAdd.addEventListener("click", () => command("tab-add"));
|
||||
elements.playlistClear.addEventListener("click", () => command("playlist-clear"));
|
||||
|
||||
async function refresh() {
|
||||
if (commandBusy) return;
|
||||
try {
|
||||
render(await api("/api/state"));
|
||||
} catch (error) {
|
||||
setStatus(`Geen verbinding: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 1000);
|
||||
Reference in New Issue
Block a user