refactoring
This commit is contained in:
+510
-245
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user