refactoring

This commit is contained in:
2026-09-01 09:31:49 +02:00
parent b3a5a0b345
commit a5a53b7efc
20 changed files with 2082 additions and 1032 deletions
+510 -245
View File
@@ -1,3 +1,20 @@
/*
* 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"),
@@ -51,6 +68,7 @@ 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);
@@ -59,8 +77,16 @@ class ApiError extends Error {
}
}
///////////////////////////////////////////////////////////////////////////////
// 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";
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");
@@ -68,18 +94,29 @@ function formatTime(value) {
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) {
const options = body === undefined
? { cache: "no-store" }
: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(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) {
@@ -88,21 +125,27 @@ async function api(path, body) {
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;
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");
@@ -116,39 +159,62 @@ async function refreshAuth() {
}
}
} catch (error) {
setStatus(t("authUnknown", { message: error.message }));
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);
if (pendingMessage) {
setStatus(pendingMessage);
}
commandBusy = true;
try {
render(await api(`/api/command/${name}`, data));
} catch (error) {
setStatus(error.message);
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((item) => {
const option = document.createElement("option");
option.value = item.id;
option.textContent = item.label;
return option;
}));
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: `${item.name} · ${item.kind === "local" ? t("rendererLocal") : item.kind.toUpperCase()}`,
label: rendererLabel(item),
}));
replaceSelect(
elements.renderer,
@@ -174,6 +240,11 @@ function renderSelectors(nextState) {
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";
@@ -188,6 +259,69 @@ function entryAction(label, title, 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);
@@ -199,98 +333,71 @@ function renderBrowser(nextState) {
const signature = nextState.browser.entries
.map((entry) => `${entry.index}:${entry.kind}:${entry.name}`)
.join("|");
if (elements.libraryEntries.dataset.signature === signature) return;
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("▶", t("playNow", { name: entry.name }), () => {
command("item-play", { index: entry.index }, t("loadingNamed", { name: entry.name }));
}),
entryAction("", t("addNamed", { name: entry.name }), () => {
command("item-add", { index: entry.index }, t("addingNamed", { name: entry.name }));
}),
);
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 }, t("loadingNamed", { name: entry.name }));
});
}
row.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
command(
entry.kind === "container" ? "browse" : "item-play",
{ index: entry.index },
entry.kind === "track" ? t("loadingNamed", { name: entry.name }) : "",
);
} else if (event.key === "+") {
command("item-add", { index: entry.index }, t("addingNamed", { name: entry.name }));
}
});
return row;
});
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 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 === "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", () => {
const newName = window.prompt(t("playlistName"), 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 = t("removePlaylist");
remove.addEventListener("click", (event) => {
event.stopPropagation();
command("tab-delete", { index: tab.index });
});
button.append(remove);
}
return button;
});
const canDelete = nextState.tabs.length > 1;
const tabs = nextState.tabs.map((tab) => createTab(tab, canDelete));
elements.tabs.replaceChildren(...tabs);
elements.tabs.dataset.signature = signature;
}
@@ -302,74 +409,91 @@ function renderTabs(nextState) {
}
}
///////////////////////////////////////////////////////////////////////////////
// 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((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 = t("removeNamed", { name: track.title });
remove.setAttribute("aria-label", t("removeNamed", { name: track.title }));
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;
});
const rows = nextState.tracks.map(createPlaylistRow);
elements.playlist.replaceChildren(...rows);
elements.playlist.dataset.signature = signature;
}
@@ -379,20 +503,38 @@ function renderPlaylist(nextState) {
}
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
elements.count.textContent = `${nextState.tracks.length} ${t(nextState.tracks.length === 1 ? "oneTrack" : "manyTracks")}`;
const trackLabel = nextState.tracks.length === 1 ? "oneTrack" : "manyTracks";
elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}`;
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 : t("nothingSelected");
elements.meta.textContent = current
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
: t("addTrackHint");
///////////////////////////////////////////////////////////////////////////////
// Playback state
///////////////////////////////////////////////////////////////////////////////
const artworkId = current?.artworkId || "";
// 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) {
@@ -405,7 +547,10 @@ function renderPlayer(nextState) {
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"));
@@ -413,46 +558,67 @@ function renderPlayer(nextState) {
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) {
elements.seek.value = nextState.duration
? Math.min(100, (nextState.position / nextState.duration) * 100)
: 0;
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";
elements.channels.textContent = nextState.channels
? `${nextState.channels} ${t(nextState.channels === 1 ? "oneChannel" : "channels")}`
: `${t("channels")}`;
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 || "—";
}
elements.coverImage.addEventListener("error", () => {
elements.coverImage.hidden = true;
elements.coverPlaceholder.hidden = false;
});
elements.coverImage.addEventListener("load", () => {
elements.coverImage.hidden = false;
elements.coverPlaceholder.hidden = true;
});
// 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);
@@ -460,81 +626,132 @@ function render(nextState) {
renderTabs(nextState);
renderPlaylist(nextState);
renderPlayer(nextState);
setStatus(nextState.error || (nextState.discovering ? t("searchingPlayers") : ""));
if (nextState.error) {
setStatus(t(nextState.error));
} else {
setStatus(nextState.discovering ? t("searchingPlayers") : "");
}
}
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.language.value = window.RktTranslate.language();
elements.language.addEventListener("change", async () => {
///////////////////////////////////////////////////////////////////////////////
// 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(error.message);
setStatus(errorMessage(error));
}
});
window.addEventListener("rkt-language-change", () => {
}
// Invalidates translated collections and rerenders them in the new language.
function handleTranslationChange() {
elements.language.value = window.RktTranslate.language();
for (const element of [elements.renderer, elements.libraryEntries, elements.tabs, elements.playlist]) {
const translatedElements = [
elements.renderer,
elements.libraryEntries,
elements.tabs,
elements.playlist,
];
for (const element of translatedElements) {
delete element.dataset.signature;
}
if (state) render(state);
});
elements.discover.addEventListener("click", async () => {
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(error.message);
setStatus(errorMessage(error));
}
});
elements.volumeToggle.addEventListener("click", () => {
const open = !elements.volumeControl.classList.contains("open");
}
// 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));
if (open) elements.volume.focus();
});
elements.volume.addEventListener("input", () => {
elements.volumeValue.value = `${elements.volume.value}%`;
});
elements.volume.addEventListener("change", () => command("volume", { value: Number(elements.volume.value) }));
document.addEventListener("pointerdown", (event) => {
if (!elements.volumeControl.contains(event.target)) {
elements.volumeControl.classList.remove("open");
elements.volumeToggle.setAttribute("aria-expanded", "false");
}
// 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();
}
});
document.addEventListener("keydown", (event) => {
}
// 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")) {
elements.volumeControl.classList.remove("open");
elements.volumeToggle.setAttribute("aria-expanded", "false");
setVolumeControlOpen(false);
elements.volumeToggle.focus();
}
});
elements.seek.addEventListener("pointerdown", () => { seekBusy = true; });
elements.seek.addEventListener("change", () => {
}
// Sends the chosen seek percentage and permits polling to update the slider again.
function seek() {
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"));
}
// Polls a complete state snapshot unless a browser command is still active.
async function refresh() {
if (commandBusy) return;
if (commandBusy) {
return;
}
try {
render(await api("/api/state"));
hideLogin();
@@ -542,12 +759,13 @@ async function refresh() {
if (error.code === "authentication-required") {
showLogin();
} else {
setStatus(t("noConnection", { message: error.message }));
setStatus(t("noConnection", { message: errorMessage(error) }));
}
}
}
elements.loginForm.addEventListener("submit", async (event) => {
// Authenticates the entered credentials, then refreshes session and player state.
async function login(event) {
event.preventDefault();
elements.loginSubmit.disabled = true;
elements.loginError.textContent = "";
@@ -560,22 +778,69 @@ elements.loginForm.addEventListener("submit", async (event) => {
await refreshAuth();
await refresh();
} catch (error) {
showLogin(error.message);
showLogin(errorMessage(error));
elements.loginPassword.select();
} finally {
elements.loginSubmit.disabled = false;
}
});
}
elements.logout.addEventListener("click", async () => {
// 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);
+60
View File
@@ -25,6 +25,12 @@
channels: "channels", oneChannel: "channel", searchingPlayers: "Searching for network players…",
authUnknown: "Authentication status unknown: {message}", noConnection: "No connection: {message}",
loggedOut: "You have been logged out.", volumePercent: "Set volume, {value} percent",
"login-rate-limited": "Too many failed sign-in attempts; try again in a few minutes.",
"invalid-credentials": "Invalid username or password.",
"json-required": "Content-Type application/json is required.",
"authentication-required": "Sign-in is required.",
"dlna-renderer-no-start-of-track-confirmation": "The DLNA renderer did not confirm the start of the track.",
"dlna-renderer-unreachable": "The DLNA renderer is unreachable.",
},
nl: {
output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
@@ -49,6 +55,12 @@
channels: "kanalen", oneChannel: "kanaal", searchingPlayers: "Netwerkspelers zoeken…",
authUnknown: "Authenticatiestatus onbekend: {message}", noConnection: "Geen verbinding: {message}",
loggedOut: "Je bent uitgelogd.", volumePercent: "Volume instellen, {value} procent",
"login-rate-limited": "Te veel mislukte aanmeldpogingen; probeer het over enkele minuten opnieuw.",
"invalid-credentials": "Ongeldige gebruikersnaam of ongeldig wachtwoord.",
"json-required": "Content-Type application/json is vereist.",
"authentication-required": "Aanmelden is vereist.",
"dlna-renderer-no-start-of-track-confirmation": "De DLNA-renderer bevestigde de start van de track niet.",
"dlna-renderer-unreachable": "De DLNA-renderer is niet bereikbaar.",
},
de: {
output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
@@ -73,6 +85,12 @@
channels: "Kanäle", oneChannel: "Kanal", searchingPlayers: "Netzwerkplayer werden gesucht…",
authUnknown: "Authentifizierungsstatus unbekannt: {message}", noConnection: "Keine Verbindung: {message}",
loggedOut: "Sie wurden abgemeldet.", volumePercent: "Lautstärke einstellen, {value} Prozent",
"login-rate-limited": "Zu viele fehlgeschlagene Anmeldeversuche; versuchen Sie es in einigen Minuten erneut.",
"invalid-credentials": "Ungültiger Benutzername oder ungültiges Passwort.",
"json-required": "Content-Type application/json ist erforderlich.",
"authentication-required": "Eine Anmeldung ist erforderlich.",
"dlna-renderer-no-start-of-track-confirmation": "Der DLNA-Renderer hat den Start des Titels nicht bestätigt.",
"dlna-renderer-unreachable": "Der DLNA-Renderer ist nicht erreichbar.",
},
fr: {
output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
@@ -97,6 +115,12 @@
channels: "canaux", oneChannel: "canal", searchingPlayers: "Recherche des lecteurs réseau…",
authUnknown: "État dauthentification inconnu : {message}", noConnection: "Aucune connexion : {message}",
loggedOut: "Vous avez été déconnecté.", volumePercent: "Régler le volume, {value} pour cent",
"login-rate-limited": "Trop de tentatives de connexion ont échoué ; réessayez dans quelques minutes.",
"invalid-credentials": "Nom dutilisateur ou mot de passe incorrect.",
"json-required": "Le Content-Type application/json est requis.",
"authentication-required": "La connexion est requise.",
"dlna-renderer-no-start-of-track-confirmation": "Le lecteur DLNA na pas confirmé le démarrage de la piste.",
"dlna-renderer-unreachable": "Le lecteur DLNA est inaccessible.",
},
es: {
output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
@@ -121,6 +145,12 @@
channels: "canales", oneChannel: "canal", searchingPlayers: "Buscando reproductores de red…",
authUnknown: "Estado de autenticación desconocido: {message}", noConnection: "Sin conexión: {message}",
loggedOut: "Has cerrado la sesión.", volumePercent: "Ajustar volumen, {value} por ciento",
"login-rate-limited": "Demasiados intentos de inicio de sesión fallidos; inténtalo de nuevo en unos minutos.",
"invalid-credentials": "Nombre de usuario o contraseña no válidos.",
"json-required": "Se requiere Content-Type application/json.",
"authentication-required": "Es necesario iniciar sesión.",
"dlna-renderer-no-start-of-track-confirmation": "El renderizador DLNA no confirmó el inicio de la pista.",
"dlna-renderer-unreachable": "No se puede acceder al renderizador DLNA.",
},
it: {
output: "USCITA", searchPlayers: "Cerca lettori di rete", logout: "ESCI",
@@ -145,6 +175,12 @@
channels: "canali", oneChannel: "canale", searchingPlayers: "Ricerca dei lettori di rete…",
authUnknown: "Stato di autenticazione sconosciuto: {message}", noConnection: "Nessuna connessione: {message}",
loggedOut: "Hai effettuato la disconnessione.", volumePercent: "Regola il volume, {value} percento",
"login-rate-limited": "Troppi tentativi di accesso non riusciti; riprova tra qualche minuto.",
"invalid-credentials": "Nome utente o password non validi.",
"json-required": "È richiesto Content-Type application/json.",
"authentication-required": "È necessario effettuare laccesso.",
"dlna-renderer-no-start-of-track-confirmation": "Il renderer DLNA non ha confermato lavvio della traccia.",
"dlna-renderer-unreachable": "Il renderer DLNA non è raggiungibile.",
},
sv: {
output: "UTGÅNG", searchPlayers: "Sök efter nätverksspelare", logout: "LOGGA UT",
@@ -169,6 +205,12 @@
channels: "kanaler", oneChannel: "kanal", searchingPlayers: "Söker efter nätverksspelare…",
authUnknown: "Okänd autentiseringsstatus: {message}", noConnection: "Ingen anslutning: {message}",
loggedOut: "Du har loggats ut.", volumePercent: "Ställ in volymen på {value} procent",
"login-rate-limited": "För många misslyckade inloggningsförsök; försök igen om några minuter.",
"invalid-credentials": "Ogiltigt användarnamn eller lösenord.",
"json-required": "Content-Type application/json krävs.",
"authentication-required": "Inloggning krävs.",
"dlna-renderer-no-start-of-track-confirmation": "DLNA-renderaren bekräftade inte att spåret startade.",
"dlna-renderer-unreachable": "DLNA-renderaren kan inte nås.",
},
no: {
output: "UTGANG", searchPlayers: "Søk etter nettverksspillere", logout: "LOGG UT",
@@ -193,6 +235,12 @@
channels: "kanaler", oneChannel: "kanal", searchingPlayers: "Søker etter nettverksspillere…",
authUnknown: "Ukjent autentiseringsstatus: {message}", noConnection: "Ingen tilkobling: {message}",
loggedOut: "Du er logget ut.", volumePercent: "Still inn volumet på {value} prosent",
"login-rate-limited": "For mange mislykkede innloggingsforsøk; prøv igjen om noen minutter.",
"invalid-credentials": "Ugyldig brukernavn eller passord.",
"json-required": "Content-Type application/json er påkrevd.",
"authentication-required": "Innlogging er påkrevd.",
"dlna-renderer-no-start-of-track-confirmation": "DLNA-gjengiveren bekreftet ikke at sporet startet.",
"dlna-renderer-unreachable": "DLNA-gjengiveren kan ikke nås.",
},
fi: {
output: "ULOSTULO", searchPlayers: "Etsi verkkosoittimia", logout: "KIRJAUDU ULOS",
@@ -217,6 +265,12 @@
channels: "kanavaa", oneChannel: "kanava", searchingPlayers: "Etsitään verkkosoittimia…",
authUnknown: "Todennuksen tila ei ole tiedossa: {message}", noConnection: "Ei yhteyttä: {message}",
loggedOut: "Olet kirjautunut ulos.", volumePercent: "Säädä äänenvoimakkuudeksi {value} prosenttia",
"login-rate-limited": "Liian monta epäonnistunutta kirjautumisyritystä; yritä uudelleen muutaman minuutin kuluttua.",
"invalid-credentials": "Virheellinen käyttäjänimi tai salasana.",
"json-required": "Content-Type application/json vaaditaan.",
"authentication-required": "Kirjautuminen vaaditaan.",
"dlna-renderer-no-start-of-track-confirmation": "DLNA-toistin ei vahvistanut kappaleen käynnistymistä.",
"dlna-renderer-unreachable": "DLNA-toistimeen ei saada yhteyttä.",
},
is: {
output: "ÚTTAK", searchPlayers: "Leita að netspilurum", logout: "SKRÁ ÚT",
@@ -241,6 +295,12 @@
channels: "rásir", oneChannel: "rás", searchingPlayers: "Leita að netspilurum…",
authUnknown: "Staða auðkenningar óþekkt: {message}", noConnection: "Engin tenging: {message}",
loggedOut: "Þú hefur skráð þig út.", volumePercent: "Stilla hljóðstyrk á {value} prósent",
"login-rate-limited": "Of margar misheppnaðar innskráningartilraunir; reyndu aftur eftir nokkrar mínútur.",
"invalid-credentials": "Ógilt notandanafn eða lykilorð.",
"json-required": "Content-Type application/json er áskilið.",
"authentication-required": "Innskráningar er krafist.",
"dlna-renderer-no-start-of-track-confirmation": "DLNA-spilarinn staðfesti ekki að spilun lagsins hefði hafist.",
"dlna-renderer-unreachable": "Ekki næst samband við DLNA-spilarann.",
},
};