Files
rkt-web-player/public/app.js
T
2026-08-26 22:12:52 +02:00

425 lines
16 KiB
JavaScript
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.
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);