/* * 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}`; } // Formats total playing time in the interface language, rounded to minutes. function formatPlayingTime(value) { const totalMinutes = Number.isFinite(value) ? Math.max(0, Math.round(value / 60)) : 0; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; const language = window.RktTranslate.language(); const parts = []; if (hours > 0) { parts.push(new Intl.NumberFormat(language, { style: "unit", unit: "hour", unitDisplay: "long", }).format(hours)); } if (minutes > 0 || hours === 0) { parts.push(new Intl.NumberFormat(language, { style: "unit", unit: "minute", unitDisplay: "long", }).format(minutes)); } const duration = new Intl.ListFormat(language, { style: "long", type: "conjunction" }).format(parts); return t("playingTime", { duration }); } // 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"; const tab = nextState.tabs[nextState.currentTab]; elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}, ${formatPlayingTime(tab.duration)}`; 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);