This commit is contained in:
2026-09-08 15:20:27 +02:00
parent 4b9e6c5651
commit 2eb9a9590e
15 changed files with 1023 additions and 127 deletions
+39 -14
View File
@@ -1,7 +1,8 @@
/*
* Browser client for the web player.
*
* refresh and command obtain complete player-state snapshots from the server.
* PlayerStateClient merges status updates with the cached playlist; tracks are
* transferred only when their version changes. State requests share a queue.
* 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.
@@ -11,6 +12,9 @@
* returned state has been rendered.
*/
import { PlayerStateClient } from "./player-state.js";
import { PlaylistLibrary } from "./playlist-library.js";
///////////////////////////////////////////////////////////////////////////////
// Browser elements and state
///////////////////////////////////////////////////////////////////////////////
@@ -44,6 +48,7 @@ const elements = {
tabAdd: document.querySelector("#tab-add"),
count: document.querySelector("#track-count"),
playlistClear: document.querySelector("#playlist-clear"),
playlistSave: document.querySelector("#playlist-save"),
playlistEmpty: document.querySelector("#playlist-empty"),
playlist: document.querySelector("#playlist"),
bits: document.querySelector("#audio-bits"),
@@ -67,6 +72,9 @@ let state = null;
let seekBusy = false;
let draggedTrack = null;
let commandBusy = false;
let refreshBusy = false;
const playerState = new PlayerStateClient(api);
const playlistLibrary = new PlaylistLibrary(document.querySelector("#library-pane"), command, formatPlayingTime);
// Represents an unsuccessful API response, including its HTTP status and code.
class ApiError extends Error {
@@ -193,7 +201,7 @@ async function command(name, data = {}, pendingMessage = "") {
commandBusy = true;
try {
render(await api(`/api/command/${name}`, data));
render(await playerState.request(`/api/command/${name}`, data));
} catch (error) {
setStatus(errorMessage(error));
} finally {
@@ -376,6 +384,14 @@ function renameTab(tab) {
}
}
// Give this tab a library name while retaining its identity and current tracks.
function savePlaylist() {
if (!state) return;
const tab = state.tabs[state.currentTab];
const name = window.prompt(t("playlistName"), tab.name === "Default" ? t("defaultPlaylist") : tab.name);
if (name !== null) command("playlist-save", { id: tab.id, name });
}
// Builds one playlist tab, including its count and optional delete control.
function createTab(tab, canDelete) {
const button = document.createElement("button");
@@ -396,11 +412,11 @@ function createTab(tab, canDelete) {
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
button.addEventListener("dblclick", () => renameTab(tab));
if (canDelete) {
if (canDelete || tab.saved) {
const remove = document.createElement("span");
remove.className = "tab-delete";
remove.textContent = "×";
remove.title = t("removePlaylist");
remove.title = t(tab.saved ? "closePlaylist" : "removePlaylist");
remove.addEventListener("click", (event) => {
event.stopPropagation();
command("tab-delete", { index: tab.index });
@@ -414,7 +430,7 @@ function createTab(tab, canDelete) {
// 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}`)
.map((tab) => `${tab.index}:${tab.name}:${tab.count}:${tab.saved}`)
.join("|");
if (elements.tabs.dataset.signature !== signature) {
const canDelete = nextState.tabs.length > 1;
@@ -510,17 +526,19 @@ function createPlaylistRow(track) {
// 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("|");
const signature = nextState.playlistVersion;
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);
const previousRow = elements.playlist.querySelector(".current");
const currentRow = Number.isInteger(nextState.currentIndex)
? elements.playlist.children[nextState.currentIndex] : null;
if (previousRow !== currentRow) {
previousRow?.classList.remove("current");
currentRow?.classList.add("current");
}
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
@@ -528,6 +546,8 @@ function renderPlaylist(nextState) {
const tab = nextState.tabs[nextState.currentTab];
elements.count.textContent = `${nextState.tracks.length} ${t(trackLabel)}, ${formatPlayingTime(tab.duration)}`;
elements.playlistClear.disabled = nextState.tracks.length === 0;
elements.playlistSave.disabled = tab.saved;
elements.playlistSave.textContent = t(tab.saved ? "playlistSaved" : "savePlaylist");
}
///////////////////////////////////////////////////////////////////////////////
@@ -645,6 +665,7 @@ function render(nextState) {
state = nextState;
renderSelectors(nextState);
renderBrowser(nextState);
playlistLibrary.render(nextState.savedPlaylists);
renderTabs(nextState);
renderPlaylist(nextState);
renderPlayer(nextState);
@@ -726,7 +747,7 @@ function handleTranslationChange() {
// Starts renderer discovery and renders the state returned by the server.
async function discoverRenderers() {
try {
render(await api("/api/discover", {}));
render(await playerState.request("/api/discover", {}));
} catch (error) {
setStatus(errorMessage(error));
}
@@ -768,14 +789,15 @@ function seek() {
command("seek", { percentage: Number(elements.seek.value) });
}
// Polls a complete state snapshot unless a browser command is still active.
// Polls status and changed tracks, without overlapping polls or busy commands.
async function refresh() {
if (commandBusy) {
if (commandBusy || refreshBusy) {
return;
}
refreshBusy = true;
try {
render(await api("/api/state"));
render(await playerState.request("/api/state"));
hideLogin();
} catch (error) {
if (error.code === "authentication-required") {
@@ -783,6 +805,8 @@ async function refresh() {
} else {
setStatus(t("noConnection", { message: errorMessage(error) }));
}
} finally {
refreshBusy = false;
}
}
@@ -859,6 +883,7 @@ elements.library.addEventListener("change", () => {
elements.libraryUp.addEventListener("click", () => command("up"));
elements.tabAdd.addEventListener("click", () => command("tab-add"));
elements.playlistClear.addEventListener("click", () => command("playlist-clear"));
elements.playlistSave.addEventListener("click", savePlaylist);
elements.loginForm.addEventListener("submit", login);
elements.logout.addEventListener("click", logout);