Files
rkt-web-player/public/app.js
T
2026-09-01 09:31:49 +02:00

847 lines
29 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.
/*
* Browser client for the web player.
*
* refresh and command obtain complete player-state snapshots from the server.
* render stores that snapshot and passes it to the selector, library, playlist,
* and playback renderers. Collection renderers retain a DOM signature so the
* one-second polling loop only rebuilds lists whose contents changed.
*
* Event handlers send commands or change local controls. command sets
* commandBusy while its request is active, so refresh skips polling until the
* returned state has been rendered.
*/
///////////////////////////////////////////////////////////////////////////////
// Browser elements and state
///////////////////////////////////////////////////////////////////////////////
const elements = {
renderer: document.querySelector("#renderer"),
language: document.querySelector("#language"),
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"),
volumeControl: document.querySelector("#volume-control"),
volumeToggle: document.querySelector("#volume-toggle"),
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"),
coverImage: document.querySelector("#cover-image"),
coverPlaceholder: document.querySelector("#cover-placeholder"),
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"),
logout: document.querySelector("#logout"),
loginOverlay: document.querySelector("#login-overlay"),
loginForm: document.querySelector("#login-form"),
loginUsername: document.querySelector("#login-username"),
loginPassword: document.querySelector("#login-password"),
loginError: document.querySelector("#login-error"),
loginSubmit: document.querySelector("#login-submit"),
};
const { t } = window.RktTranslate;
let state = null;
let seekBusy = false;
let draggedTrack = null;
let commandBusy = false;
// Represents an unsuccessful API response, including its HTTP status and code.
class ApiError extends Error {
constructor(message, status, code) {
super(message);
this.status = status;
this.code = code;
}
}
///////////////////////////////////////////////////////////////////////////////
// API, authentication, and shared formatting
///////////////////////////////////////////////////////////////////////////////
// Converts a duration in seconds to the fixed-width time shown by the player.
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}`;
}
// Replaces the current status message, using an empty string for no message.
function setStatus(message) {
elements.status.textContent = message || "";
}
// Translates an API error code while retaining literal messages as a fallback.
function errorMessage(error) {
return t(error.message);
}
// Sends a GET or JSON POST request and returns its decoded JSON response.
async function api(path, body) {
let options;
if (body === undefined) {
options = { cache: "no-store" };
} else {
options = {
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 ApiError(data.error || `HTTP ${response.status}`, response.status, data.code);
}
return data;
}
// Displays the login overlay and optionally reports why authentication failed.
function showLogin(message = null) {
const wasHidden = elements.loginOverlay.hidden;
if (message !== null) {
elements.loginError.textContent = message;
}
elements.loginOverlay.hidden = false;
if (wasHidden) {
window.setTimeout(() => elements.loginUsername.focus(), 0);
}
}
// Hides the login overlay and clears credentials that must not be retained.
function hideLogin() {
elements.loginOverlay.hidden = true;
elements.loginError.textContent = "";
elements.loginPassword.value = "";
}
// Synchronizes the login controls and stored language with the current session.
async function refreshAuth() {
try {
const auth = await api("/api/auth/status");
elements.logout.hidden = !auth.enabled || !auth.authenticated;
if (auth.enabled && !auth.authenticated) {
showLogin();
} else {
const preferences = await api("/api/preferences");
if (window.RktTranslate.supported.includes(preferences.language)) {
window.RktTranslate.setLanguage(preferences.language);
}
}
} catch (error) {
setStatus(t("authUnknown", { message: errorMessage(error) }));
}
}
// Sends a player command and immediately renders the state returned by it.
// commandBusy makes refresh skip polling until this request has finished.
async function command(name, data = {}, pendingMessage = "") {
if (pendingMessage) {
setStatus(pendingMessage);
}
commandBusy = true;
try {
render(await api(`/api/command/${name}`, data));
} catch (error) {
setStatus(errorMessage(error));
} finally {
commandBusy = false;
}
}
///////////////////////////////////////////////////////////////////////////////
// Output and library selectors
///////////////////////////////////////////////////////////////////////////////
// Creates one option for a renderer or music-library selector.
function createOption(item) {
const option = document.createElement("option");
option.value = item.id;
option.textContent = item.label;
return option;
}
// Rebuilds a select only when its available items changed, then selects its value.
function replaceSelect(select, items, selectedId, signature) {
if (select.dataset.signature !== signature) {
select.replaceChildren(...items.map(createOption));
select.dataset.signature = signature;
}
select.value = selectedId || "";
}
// Builds the renderer label, translating the special local-renderer kind.
function rendererLabel(renderer) {
let kind = renderer.kind.toUpperCase();
if (renderer.kind === "local") {
kind = t("rendererLocal");
}
return `${renderer.name} · ${kind}`;
}
// Renders output and library choices and their current availability.
function renderSelectors(nextState) {
const rendererItems = nextState.renderers.map((item) => ({
id: item.id,
label: rendererLabel(item),
}));
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.renderer.disabled = nextState.renderers.length === 0;
elements.library.disabled = nextState.libraries.length === 0;
}
///////////////////////////////////////////////////////////////////////////////
// Music-library browser
///////////////////////////////////////////////////////////////////////////////
// Creates an action button that does not activate its surrounding library row.
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;
}
// Starts a library track immediately and reports which item is being loaded.
function playLibraryEntry(entry) {
const message = t("loadingNamed", { name: entry.name });
command("item-play", { index: entry.index }, message);
}
// Adds a library item to the current playlist and reports the pending action.
function addLibraryEntry(entry) {
const message = t("addingNamed", { name: entry.name });
command("item-add", { index: entry.index }, message);
}
// Opens a container or starts a track, according to the entry kind.
function activateLibraryEntry(entry) {
if (entry.kind === "container") {
command("browse", { index: entry.index });
} else {
playLibraryEntry(entry);
}
}
// Maps the keyboard actions supported by a focused library entry.
function handleLibraryEntryKeydown(event, entry) {
if (event.key === "Enter") {
activateLibraryEntry(entry);
} else if (event.key === "+") {
addLibraryEntry(entry);
}
}
// Builds an interactive row for one container or track in the library browser.
function createLibraryEntry(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("▶", t("playNow", { name: entry.name }), () => playLibraryEntry(entry)),
entryAction("", t("addNamed", { name: entry.name }), () => addLibraryEntry(entry)),
);
row.append(icon, name, actions);
if (entry.kind === "container") {
row.addEventListener("click", () => activateLibraryEntry(entry));
} else {
row.addEventListener("dblclick", () => activateLibraryEntry(entry));
}
row.addEventListener("keydown", (event) => handleLibraryEntryKeydown(event, entry));
return row;
}
// Renders the current library path and rebuilds changed directory contents.
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(" / ") : t("noLibrary");
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(createLibraryEntry);
elements.libraryEntries.replaceChildren(...rows);
elements.libraryEntries.dataset.signature = signature;
}
///////////////////////////////////////////////////////////////////////////////
// Playlist tabs
///////////////////////////////////////////////////////////////////////////////
// Prompts for and submits a new name for an existing playlist tab.
function renameTab(tab) {
const newName = window.prompt(t("playlistName"), tab.name);
if (newName !== null) {
command("tab-rename", { index: tab.index, name: newName });
}
}
// Builds one playlist tab, including its count and optional delete control.
function createTab(tab, canDelete) {
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 === "Default" ? t("defaultPlaylist") : 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", () => renameTab(tab));
if (canDelete) {
const remove = document.createElement("span");
remove.className = "tab-delete";
remove.textContent = "×";
remove.title = t("removePlaylist");
remove.addEventListener("click", (event) => {
event.stopPropagation();
command("tab-delete", { index: tab.index });
});
button.append(remove);
}
return button;
}
// Rebuilds changed tabs and marks the server-selected tab as active.
function renderTabs(nextState) {
const signature = nextState.tabs
.map((tab) => `${tab.index}:${tab.name}:${tab.count}`)
.join("|");
if (elements.tabs.dataset.signature !== signature) {
const canDelete = nextState.tabs.length > 1;
const tabs = nextState.tabs.map((tab) => createTab(tab, canDelete));
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");
}
}
///////////////////////////////////////////////////////////////////////////////
// Playlist tracks
///////////////////////////////////////////////////////////////////////////////
// Maps playback and deletion keys for a focused playlist row.
function handlePlaylistRowKeydown(event, track) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
command("play", { index: track.index });
} else if (event.key === "Delete") {
command("track-remove", { index: track.index });
}
}
// Moves the dragged track to this row and clears the transient drag state.
function dropTrack(event, track) {
event.preventDefault();
if (Number.isInteger(draggedTrack) && draggedTrack !== track.index) {
command("track-move", { from: draggedTrack, to: track.index });
}
draggedTrack = null;
}
// Builds a playlist table row with playback, removal, and drag actions.
function createPlaylistRow(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");
const removeTitle = t("removeNamed", { name: track.title });
remove.className = "row-action";
remove.type = "button";
remove.textContent = "×";
remove.title = removeTitle;
remove.setAttribute("aria-label", removeTitle);
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) => handlePlaylistRowKeydown(event, track));
row.addEventListener("dragstart", () => {
draggedTrack = track.index;
});
row.addEventListener("dragover", (event) => event.preventDefault());
row.addEventListener("drop", (event) => dropTrack(event, track));
return row;
}
// Rebuilds changed tracks and updates the active row and playlist summary.
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(createPlaylistRow);
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;
const trackLabel = nextState.tracks.length === 1 ? "oneTrack" : "manyTracks";
elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}`;
elements.playlistClear.disabled = nextState.tracks.length === 0;
}
///////////////////////////////////////////////////////////////////////////////
// Playback state
///////////////////////////////////////////////////////////////////////////////
// Returns the selected track, or null when the server has no valid selection.
function currentTrack(nextState) {
if (!Number.isInteger(nextState.currentIndex)) {
return null;
}
return nextState.tracks[nextState.currentIndex] || null;
}
// Renders the selected track's title and description or the empty-state text.
function renderCurrentTrack(track) {
if (track) {
const description = [track.artist, track.album].filter(Boolean).join(" · ");
elements.title.textContent = track.title;
elements.meta.textContent = description || track.source;
} else {
elements.title.textContent = t("nothingSelected");
elements.meta.textContent = t("addTrackHint");
}
}
// Updates the cover source only when the selected artwork actually changes.
function renderArtwork(track) {
const artworkId = track?.artworkId || "";
if (elements.coverImage.dataset.artworkId !== artworkId) {
elements.coverImage.dataset.artworkId = artworkId;
if (artworkId) {
elements.coverImage.hidden = false;
elements.coverPlaceholder.hidden = true;
elements.coverImage.src = `/api/artwork/${encodeURIComponent(artworkId)}`;
} else {
elements.coverImage.removeAttribute("src");
elements.coverImage.hidden = true;
elements.coverPlaceholder.hidden = false;
}
}
}
// Reflects playback state and command availability in the transport controls.
function renderPlaybackControls(nextState) {
const playing = nextState.state === "playing" || nextState.state === "starting";
elements.play.textContent = playing ? "Ⅱ" : "▶";
elements.play.setAttribute("aria-label", t(playing ? "pause" : "play"));
elements.play.disabled = nextState.tracks.length === 0 || nextState.renderers.length === 0;
elements.previous.disabled = nextState.tracks.length === 0;
elements.next.disabled = nextState.tracks.length === 0;
elements.stop.disabled = nextState.state === "stopped";
}
// Updates elapsed time and seek position without disturbing an active drag.
function renderPosition(nextState) {
elements.position.textContent = formatTime(nextState.position);
elements.duration.textContent = formatTime(nextState.duration);
if (!seekBusy) {
let percentage = 0;
if (nextState.duration) {
percentage = Math.min(100, (nextState.position / nextState.duration) * 100);
}
elements.seek.value = percentage;
}
elements.seek.disabled = !nextState.duration;
}
// Synchronizes the volume slider, value display, and accessible label.
function renderVolume(nextState) {
elements.volume.value = nextState.volume;
elements.volumeValue.value = `${Math.round(nextState.volume)}%`;
elements.volumeToggle.setAttribute(
"aria-label",
t("volumePercent", { value: Math.round(nextState.volume) }),
);
}
// Shows the active repeat mode and its translated description.
function renderRepeatMode(nextState) {
elements.repeat.dataset.repeat = nextState.repeat;
elements.repeat.classList.toggle("active", nextState.repeat !== "off");
const repeatNames = { off: t("repeatOff"), all: t("repeatAll"), one: t("repeatOne") };
elements.repeat.title = repeatNames[nextState.repeat] || repeatNames.off;
}
// Renders the technical properties reported for the current audio source.
function renderAudioDetails(nextState) {
elements.bits.textContent = nextState.bits ? `${nextState.bits} bit` : "— bit";
elements.rate.textContent = nextState.rate ? `${(nextState.rate / 1000).toFixed(1)} kHz` : "— kHz";
if (nextState.channels) {
const channelLabel = nextState.channels === 1 ? "oneChannel" : "channels";
elements.channels.textContent = `${nextState.channels} ${t(channelLabel)}`;
} else {
elements.channels.textContent = `${t("channels")}`;
}
elements.format.textContent = nextState.format || "—";
elements.source.textContent = nextState.source || "—";
}
// Delegates the player portion of a state snapshot to its visible subregions.
function renderPlayer(nextState) {
const track = currentTrack(nextState);
renderCurrentTrack(track);
renderArtwork(track);
renderPlaybackControls(nextState);
renderPosition(nextState);
renderVolume(nextState);
renderRepeatMode(nextState);
renderAudioDetails(nextState);
}
// Stores and renders one complete state snapshot returned by the server.
function render(nextState) {
state = nextState;
renderSelectors(nextState);
renderBrowser(nextState);
renderTabs(nextState);
renderPlaylist(nextState);
renderPlayer(nextState);
if (nextState.error) {
setStatus(t(nextState.error));
} else {
setStatus(nextState.discovering ? t("searchingPlayers") : "");
}
}
///////////////////////////////////////////////////////////////////////////////
// User interaction and polling
///////////////////////////////////////////////////////////////////////////////
// Chooses whether the play button must start, resume, or pause playback.
function commandForPlayButton(playbackState) {
const playing = playbackState.state === "playing" || playbackState.state === "starting";
if (playing) {
return "pause";
}
if (playbackState.state === "paused") {
return "resume";
}
return "play";
}
// Sends the command represented by the play button in the current state.
function handlePlay() {
if (state) {
command(commandForPlayButton(state));
}
}
// Advances repeat mode through off, all tracks, and one track.
function nextRepeatMode(currentMode) {
if (currentMode === "off") {
return "all";
}
if (currentMode === "all") {
return "one";
}
return "off";
}
// Sends the next repeat mode when state has already been received.
function handleRepeat() {
if (state) {
command("repeat", { mode: nextRepeatMode(state.repeat) });
}
}
// Applies and persists the language selected in the browser.
async function handleLanguageChange() {
window.RktTranslate.setLanguage(elements.language.value);
try {
await api("/api/preferences", { language: elements.language.value });
} catch (error) {
setStatus(errorMessage(error));
}
}
// Invalidates translated collections and rerenders them in the new language.
function handleTranslationChange() {
elements.language.value = window.RktTranslate.language();
const translatedElements = [
elements.renderer,
elements.libraryEntries,
elements.tabs,
elements.playlist,
];
for (const element of translatedElements) {
delete element.dataset.signature;
}
if (state) {
render(state);
}
}
// Starts renderer discovery and renders the state returned by the server.
async function discoverRenderers() {
try {
render(await api("/api/discover", {}));
} catch (error) {
setStatus(errorMessage(error));
}
}
// Opens or closes the volume popover and keeps its ARIA state synchronized.
function setVolumeControlOpen(open) {
elements.volumeControl.classList.toggle("open", open);
elements.volumeToggle.setAttribute("aria-expanded", String(open));
}
// Toggles the volume popover and moves focus to its slider when opened.
function toggleVolumeControl() {
const open = !elements.volumeControl.classList.contains("open");
setVolumeControlOpen(open);
if (open) {
elements.volume.focus();
}
}
// Closes the volume popover when the pointer is pressed outside its controls.
function closeVolumeControlOnOutsideClick(event) {
if (!elements.volumeControl.contains(event.target)) {
setVolumeControlOpen(false);
}
}
// Closes the volume popover with Escape and returns focus to its button.
function closeVolumeControlOnEscape(event) {
if (event.key === "Escape" && elements.volumeControl.classList.contains("open")) {
setVolumeControlOpen(false);
elements.volumeToggle.focus();
}
}
// Sends the chosen seek percentage and permits polling to update the slider again.
function seek() {
seekBusy = false;
command("seek", { percentage: Number(elements.seek.value) });
}
// Polls a complete state snapshot unless a browser command is still active.
async function refresh() {
if (commandBusy) {
return;
}
try {
render(await api("/api/state"));
hideLogin();
} catch (error) {
if (error.code === "authentication-required") {
showLogin();
} else {
setStatus(t("noConnection", { message: errorMessage(error) }));
}
}
}
// Authenticates the entered credentials, then refreshes session and player state.
async function login(event) {
event.preventDefault();
elements.loginSubmit.disabled = true;
elements.loginError.textContent = "";
try {
await api("/api/auth/login", {
username: elements.loginUsername.value,
password: elements.loginPassword.value,
});
hideLogin();
await refreshAuth();
await refresh();
} catch (error) {
showLogin(errorMessage(error));
elements.loginPassword.select();
} finally {
elements.loginSubmit.disabled = false;
}
}
// Ends the server session and returns the browser to the login overlay.
async function logout() {
try {
await api("/api/auth/logout", {});
} finally {
elements.logout.hidden = true;
showLogin(t("loggedOut"));
}
}
///////////////////////////////////////////////////////////////////////////////
// Event registration and initialization
///////////////////////////////////////////////////////////////////////////////
elements.coverImage.addEventListener("error", () => {
elements.coverImage.hidden = true;
elements.coverPlaceholder.hidden = false;
});
elements.coverImage.addEventListener("load", () => {
elements.coverImage.hidden = false;
elements.coverPlaceholder.hidden = true;
});
elements.play.addEventListener("click", handlePlay);
elements.previous.addEventListener("click", () => command("previous"));
elements.stop.addEventListener("click", () => command("stop"));
elements.next.addEventListener("click", () => command("next"));
elements.repeat.addEventListener("click", handleRepeat);
elements.renderer.addEventListener("change", () => {
command("renderer", { id: elements.renderer.value });
});
elements.language.addEventListener("change", handleLanguageChange);
window.addEventListener("rkt-language-change", handleTranslationChange);
elements.discover.addEventListener("click", discoverRenderers);
elements.volumeToggle.addEventListener("click", toggleVolumeControl);
elements.volume.addEventListener("input", () => {
elements.volumeValue.value = `${elements.volume.value}%`;
});
elements.volume.addEventListener("change", () => {
command("volume", { value: Number(elements.volume.value) });
});
document.addEventListener("pointerdown", closeVolumeControlOnOutsideClick);
document.addEventListener("keydown", closeVolumeControlOnEscape);
elements.seek.addEventListener("pointerdown", () => {
seekBusy = true;
});
elements.seek.addEventListener("change", seek);
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"));
elements.loginForm.addEventListener("submit", login);
elements.logout.addEventListener("click", logout);
elements.language.value = window.RktTranslate.language();
refreshAuth();
refresh();
setInterval(refresh, 1000);