multi player / per user playing and translation

This commit is contained in:
2026-08-28 16:53:05 +02:00
parent 2743dfb4c0
commit e10f2cf7d7
17 changed files with 1432 additions and 482 deletions
+80 -26
View File
@@ -1,5 +1,6 @@
const elements = {
renderer: document.querySelector("#renderer"),
language: document.querySelector("#language"),
discover: document.querySelector("#discover"),
previous: document.querySelector("#previous"),
play: document.querySelector("#play"),
@@ -9,6 +10,8 @@ const elements = {
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"),
@@ -41,6 +44,8 @@ const elements = {
loginSubmit: document.querySelector("#login-submit"),
};
const { t } = window.RktTranslate;
let state = null;
let seekBusy = false;
let draggedTrack = null;
@@ -102,9 +107,16 @@ async function refreshAuth() {
try {
const auth = await api("/api/auth/status");
elements.logout.hidden = !auth.enabled || !auth.authenticated;
if (auth.enabled && !auth.authenticated) showLogin();
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(`Authenticatiestatus onbekend: ${error.message}`);
setStatus(t("authUnknown", { message: error.message }));
}
}
@@ -136,7 +148,7 @@ function replaceSelect(select, items, selectedId, signature) {
function renderSelectors(nextState) {
const rendererItems = nextState.renderers.map((item) => ({
id: item.id,
label: `${item.name} · ${item.kind.toUpperCase()}`,
label: `${item.name} · ${item.kind === "local" ? t("rendererLocal") : item.kind.toUpperCase()}`,
}));
replaceSelect(
elements.renderer,
@@ -158,6 +170,7 @@ function renderSelectors(nextState) {
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;
}
@@ -178,7 +191,7 @@ function entryAction(label, title, handler) {
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(" / ") : "Geen bibliotheek";
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;
@@ -205,11 +218,11 @@ function renderBrowser(nextState) {
const actions = document.createElement("span");
actions.className = "entry-actions";
actions.append(
entryAction("▶", `${entry.name} nu afspelen`, () => {
command("item-play", { index: entry.index }, `${entry.name} laden…`);
entryAction("▶", t("playNow", { name: entry.name }), () => {
command("item-play", { index: entry.index }, t("loadingNamed", { name: entry.name }));
}),
entryAction("", `${entry.name} toevoegen`, () => {
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
entryAction("", t("addNamed", { name: entry.name }), () => {
command("item-add", { index: entry.index }, t("addingNamed", { name: entry.name }));
}),
);
@@ -218,7 +231,7 @@ function renderBrowser(nextState) {
row.addEventListener("click", () => command("browse", { index: entry.index }));
} else {
row.addEventListener("dblclick", () => {
command("item-play", { index: entry.index }, `${entry.name} laden…`);
command("item-play", { index: entry.index }, t("loadingNamed", { name: entry.name }));
});
}
row.addEventListener("keydown", (event) => {
@@ -226,10 +239,10 @@ function renderBrowser(nextState) {
command(
entry.kind === "container" ? "browse" : "item-play",
{ index: entry.index },
entry.kind === "track" ? `${entry.name} laden…` : "",
entry.kind === "track" ? t("loadingNamed", { name: entry.name }) : "",
);
} else if (event.key === "+") {
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
command("item-add", { index: entry.index }, t("addingNamed", { name: entry.name }));
}
});
return row;
@@ -253,7 +266,7 @@ function renderTabs(nextState) {
const name = document.createElement("span");
name.className = "tab-name";
name.textContent = tab.name;
name.textContent = tab.name === "Default" ? t("defaultPlaylist") : tab.name;
const count = document.createElement("span");
count.className = "tab-count";
count.textContent = tab.count;
@@ -261,7 +274,7 @@ function renderTabs(nextState) {
button.addEventListener("click", () => command("tab-select", { index: tab.index }));
button.addEventListener("dblclick", () => {
const newName = window.prompt("Naam van de afspeellijst", tab.name);
const newName = window.prompt(t("playlistName"), tab.name);
if (newName !== null) command("tab-rename", { index: tab.index, name: newName });
});
@@ -269,7 +282,7 @@ function renderTabs(nextState) {
const remove = document.createElement("span");
remove.className = "tab-delete";
remove.textContent = "×";
remove.title = "Afspeellijst verwijderen";
remove.title = t("removePlaylist");
remove.addEventListener("click", (event) => {
event.stopPropagation();
command("tab-delete", { index: tab.index });
@@ -328,8 +341,8 @@ function renderPlaylist(nextState) {
remove.className = "row-action";
remove.type = "button";
remove.textContent = "×";
remove.title = `${track.title} verwijderen`;
remove.setAttribute("aria-label", `${track.title} verwijderen`);
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 });
@@ -366,7 +379,7 @@ function renderPlaylist(nextState) {
}
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
elements.count.textContent = `${nextState.tracks.length} ${nextState.tracks.length === 1 ? "track" : "tracks"}`;
elements.count.textContent = `${nextState.tracks.length} ${t(nextState.tracks.length === 1 ? "oneTrack" : "manyTracks")}`;
elements.playlistClear.disabled = nextState.tracks.length === 0;
}
@@ -374,10 +387,10 @@ function renderPlayer(nextState) {
const current = Number.isInteger(nextState.currentIndex)
? nextState.tracks[nextState.currentIndex]
: null;
elements.title.textContent = current ? current.title : "Nog niets gekozen";
elements.title.textContent = current ? current.title : t("nothingSelected");
elements.meta.textContent = current
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
: "Voeg een track toe vanuit de bibliotheek";
: t("addTrackHint");
const artworkId = current?.artworkId || "";
if (elements.coverImage.dataset.artworkId !== artworkId) {
@@ -395,8 +408,8 @@ function renderPlayer(nextState) {
const playing = nextState.state === "playing" || nextState.state === "starting";
elements.play.textContent = playing ? "Ⅱ" : "▶";
elements.play.setAttribute("aria-label", playing ? "Pauzeren" : "Afspelen");
elements.play.disabled = nextState.tracks.length === 0;
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";
@@ -412,14 +425,20 @@ function renderPlayer(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) }),
);
elements.repeat.dataset.repeat = nextState.repeat;
elements.repeat.classList.toggle("active", nextState.repeat !== "off");
const repeatNames = { off: "Herhalen uit", all: "Alles herhalen", one: "Eén track herhalen" };
const repeatNames = { off: t("repeatOff"), all: t("repeatAll"), one: t("repeatOne") };
elements.repeat.title = repeatNames[nextState.repeat] || repeatNames.off;
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} kanalen` : "— kanalen";
elements.channels.textContent = nextState.channels
? `${nextState.channels} ${t(nextState.channels === 1 ? "oneChannel" : "channels")}`
: `${t("channels")}`;
elements.format.textContent = nextState.format || "—";
elements.source.textContent = nextState.source || "—";
}
@@ -441,7 +460,7 @@ function render(nextState) {
renderTabs(nextState);
renderPlaylist(nextState);
renderPlayer(nextState);
setStatus(nextState.error || (nextState.discovering ? "Netwerkspelers zoeken…" : ""));
setStatus(nextState.error || (nextState.discovering ? t("searchingPlayers") : ""));
}
elements.play.addEventListener("click", () => {
@@ -458,6 +477,22 @@ elements.repeat.addEventListener("click", () => {
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 () => {
window.RktTranslate.setLanguage(elements.language.value);
try {
await api("/api/preferences", { language: elements.language.value });
} catch (error) {
setStatus(error.message);
}
});
window.addEventListener("rkt-language-change", () => {
elements.language.value = window.RktTranslate.language();
for (const element of [elements.renderer, elements.libraryEntries, elements.tabs, elements.playlist]) {
delete element.dataset.signature;
}
if (state) render(state);
});
elements.discover.addEventListener("click", async () => {
try {
render(await api("/api/discover", {}));
@@ -465,10 +500,29 @@ elements.discover.addEventListener("click", async () => {
setStatus(error.message);
}
});
elements.volumeToggle.addEventListener("click", () => {
const open = !elements.volumeControl.classList.contains("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");
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && elements.volumeControl.classList.contains("open")) {
elements.volumeControl.classList.remove("open");
elements.volumeToggle.setAttribute("aria-expanded", "false");
elements.volumeToggle.focus();
}
});
elements.seek.addEventListener("pointerdown", () => { seekBusy = true; });
elements.seek.addEventListener("change", () => {
seekBusy = false;
@@ -488,7 +542,7 @@ async function refresh() {
if (error.code === "authentication-required") {
showLogin();
} else {
setStatus(`Geen verbinding: ${error.message}`);
setStatus(t("noConnection", { message: error.message }));
}
}
}
@@ -518,7 +572,7 @@ elements.logout.addEventListener("click", async () => {
await api("/api/auth/logout", {});
} finally {
elements.logout.hidden = true;
showLogin("Je bent uitgelogd.");
showLogin(t("loggedOut"));
}
});
+48 -41
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="nl">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -16,23 +16,26 @@
</div>
<div class="output-control">
<label for="renderer">UITVOER</label>
<label for="renderer" data-i18n="output">OUTPUT</label>
<select id="renderer"></select>
<button id="discover" class="square-button" type="button" title="Netwerkspelers zoeken" aria-label="Netwerkspelers zoeken"></button>
<button id="logout" class="text-button" type="button" hidden>UITLOGGEN</button>
<button id="discover" class="square-button" type="button" data-i18n-title="searchPlayers" title="Search for network players" aria-label="Search for network players"></button>
<select id="language" class="language-select" aria-label="Language">
<option value="en">EN</option><option value="nl">NL</option><option value="de">DE</option><option value="fr">FR</option><option value="es">ES</option>
</select>
<button id="logout" class="text-button" type="button" data-i18n="logout" data-i18n-aria="logout" aria-label="Log out" hidden>LOG OUT</button>
</div>
</header>
<section class="transport-bar" aria-label="Afspeelbediening">
<section class="transport-bar" data-i18n-aria="playbackControls" aria-label="Playback controls">
<div class="transport-buttons">
<button id="previous" class="command-button" type="button" aria-label="Vorige track"></button>
<button id="play" class="command-button play-button" type="button" aria-label="Afspelen of pauzeren"></button>
<button id="stop" class="command-button" type="button" aria-label="Stoppen"></button>
<button id="next" class="command-button" type="button" aria-label="Volgende track"></button>
<button id="previous" class="command-button" type="button" data-i18n-aria="previousTrack" aria-label="Previous track"></button>
<button id="play" class="command-button play-button" type="button" data-i18n-aria="playOrPause" aria-label="Play or pause"></button>
<button id="stop" class="command-button" type="button" data-i18n-aria="stop" aria-label="Stop"></button>
<button id="next" class="command-button" type="button" data-i18n-aria="nextTrack" aria-label="Next track"></button>
</div>
<div class="seek-control">
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" aria-label="Afspeelpositie">
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" data-i18n-aria="playbackPosition" aria-label="Playback position">
<div class="time-display">
<span id="position">00:00:00</span>
<span class="time-divider">/</span>
@@ -40,12 +43,15 @@
</div>
</div>
<button id="repeat" class="command-button repeat-button" type="button" aria-label="Herhaalmodus"></button>
<label class="volume-control" for="volume">
<span aria-hidden="true">VOL</span>
<input id="volume" type="range" min="0" max="100" value="50">
<output id="volume-value">50%</output>
</label>
<button id="repeat" class="command-button repeat-button" type="button" data-i18n-aria="repeatMode" aria-label="Repeat mode"></button>
<div id="volume-control" class="volume-control">
<button id="volume-toggle" class="volume-toggle" type="button" data-i18n-aria="setVolume" aria-label="Set volume" aria-controls="volume-slider" aria-expanded="false">VOL</button>
<label id="volume-slider" class="volume-slider" for="volume">
<span aria-hidden="true">VOL</span>
<input id="volume" type="range" min="0" max="100" value="50" data-i18n-aria="volume" aria-label="Volume">
<output id="volume-value">50%</output>
</label>
</div>
</section>
<section class="workspace">
@@ -53,17 +59,17 @@
<section class="library-pane panel">
<div class="panel-header library-header">
<div>
<span class="panel-kicker">MUZIEKBIBLIOTHEEK</span>
<select id="library" aria-label="Muziekbibliotheek"></select>
<span class="panel-kicker" data-i18n="musicLibrary">MUSIC LIBRARY</span>
<select id="library" data-i18n-aria="musicLibrary" aria-label="Music library"></select>
</div>
<button id="library-up" class="square-button" type="button" title="Eén map omhoog" aria-label="Eén map omhoog"></button>
<button id="library-up" class="square-button" type="button" data-i18n-title="upFolder" title="Up one folder" aria-label="Up one folder"></button>
</div>
<nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav>
<nav id="breadcrumb" class="breadcrumb" data-i18n-aria="currentFolder" aria-label="Current folder"></nav>
<div id="library-empty" class="empty-state" hidden>
<p>Geen bibliotheek geconfigureerd.</p>
<p data-i18n="noLibraryConfigured">No library configured.</p>
<code>[libraries] muziek=D:\Muziek</code>
</div>
<ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul>
<ul id="library-entries" class="library-list" data-i18n-aria="folderContents" aria-label="Folder contents"></ul>
</section>
<section class="now-playing-pane panel">
@@ -75,30 +81,30 @@
<img id="cover-image" alt="" hidden>
</div>
<div class="track-summary">
<span class="panel-kicker">NU AAN HET SPELEN</span>
<h1 id="now-title">Nog niets gekozen</h1>
<p id="now-meta">Voeg een track toe vanuit de bibliotheek</p>
<span class="panel-kicker" data-i18n="nowPlaying">NOW PLAYING</span>
<h1 id="now-title" data-i18n="nothingSelected">Nothing selected yet</h1>
<p id="now-meta" data-i18n="addTrackHint">Add a track from the library</p>
</div>
</section>
</aside>
<section class="playlist-pane panel">
<div class="tabs-bar">
<div id="tabs" class="tabs" role="tablist" aria-label="Afspeellijsten"></div>
<button id="tab-add" class="tab-add" type="button" title="Nieuwe afspeellijst" aria-label="Nieuwe afspeellijst"></button>
<div id="tabs" class="tabs" role="tablist" data-i18n-aria="playlists" aria-label="Playlists"></div>
<button id="tab-add" class="tab-add" type="button" data-i18n-title="newPlaylist" title="New playlist" aria-label="New playlist"></button>
</div>
<div class="playlist-toolbar">
<div>
<span class="panel-kicker">AFSPEELLIJST</span>
<span class="panel-kicker" data-i18n="playlist">PLAYLIST</span>
<strong id="track-count">0 tracks</strong>
</div>
<button id="playlist-clear" class="text-button" type="button">LIJST WISSEN</button>
<button id="playlist-clear" class="text-button" type="button" data-i18n="clearList">CLEAR LIST</button>
</div>
<div id="playlist-empty" class="empty-state playlist-empty">
<p>Deze afspeellijst is leeg.</p>
<span>Gebruik <strong></strong> bij een track of map.</span>
<p data-i18n="emptyPlaylist">This playlist is empty.</p>
<span data-i18n="emptyPlaylistHint">Use next to a track or folder.</span>
</div>
<div class="playlist-scroll">
@@ -106,10 +112,10 @@
<thead>
<tr>
<th class="number-column">#</th>
<th>TITEL</th>
<th>ALBUM</th>
<th class="duration-column">DUUR</th>
<th class="action-column"><span class="visually-hidden">Acties</span></th>
<th data-i18n="title">TITLE</th>
<th data-i18n="album">ALBUM</th>
<th class="duration-column" data-i18n="duration">DURATION</th>
<th class="action-column"><span class="visually-hidden" data-i18n="actions">Actions</span></th>
</tr>
</thead>
<tbody id="playlist"></tbody>
@@ -122,11 +128,11 @@
<div class="audio-status">
<span id="audio-bits">— bit</span>
<span id="audio-rate">— kHz</span>
<span id="audio-channels">kanalen</span>
<span id="audio-channels">channels</span>
<span id="audio-format"></span>
<span id="audio-source"></span>
</div>
<p id="status" role="status" aria-live="polite">Verbinden</p>
<p id="status" role="status" aria-live="polite" data-i18n="connecting">Connecting</p>
</footer>
</main>
@@ -134,16 +140,17 @@
<form id="login-form" class="login-panel">
<span class="brand-mark">RKT</span>
<p class="panel-kicker">WEB PLAYER</p>
<h1 id="login-title">Aanmelden</h1>
<label for="login-username">Gebruikersnaam</label>
<h1 id="login-title" data-i18n="signIn">Sign in</h1>
<label for="login-username" data-i18n="username">Username</label>
<input id="login-username" name="username" autocomplete="username" required>
<label for="login-password">Wachtwoord</label>
<label for="login-password" data-i18n="password">Password</label>
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
<p id="login-error" class="login-error" role="alert"></p>
<button id="login-submit" class="text-button" type="submit">AANMELDEN</button>
<button id="login-submit" class="text-button" type="submit" data-i18n="signIn">Sign in</button>
</form>
</section>
<script src="/translate.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>
+95 -7
View File
@@ -43,6 +43,12 @@ select {
color: var(--text);
}
.language-select {
width: 58px;
min-width: 58px;
padding-right: 18px;
}
button {
cursor: pointer;
}
@@ -276,19 +282,31 @@ input[type="range"] {
.volume-control {
align-self: stretch;
width: 210px;
gap: 9px;
padding: 0 12px;
position: relative;
border-left: 1px solid var(--line);
}
.volume-control span,
.volume-control output {
.volume-slider {
display: flex;
width: 100%;
height: 100%;
gap: 9px;
align-items: center;
padding: 0 12px;
}
.volume-toggle {
display: none;
}
.volume-slider span,
.volume-slider output {
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
.volume-control output {
.volume-slider output {
width: 32px;
font-variant-numeric: tabular-nums;
}
@@ -861,7 +879,26 @@ input:focus-visible,
}
.output-control select {
max-width: 180px;
max-width: 140px;
}
.output-control .language-select {
width: 52px;
min-width: 52px;
}
#logout {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
overflow: hidden;
font-size: 0;
}
#logout::after {
content: "↪";
font-size: 17px;
}
.transport-buttons .command-button {
@@ -873,7 +910,58 @@ input:focus-visible,
}
.volume-control {
flex: 1;
flex: 0 0 48px;
width: 48px;
padding: 0;
}
.volume-toggle {
display: grid;
width: 47px;
height: 52px;
padding: 0;
place-items: center;
border: 0;
background: transparent;
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
.volume-toggle:hover,
.volume-toggle[aria-expanded="true"] {
background: var(--accent-soft);
color: var(--accent);
}
.volume-slider {
position: absolute;
z-index: 20;
top: calc(100% + 6px);
right: 0;
display: none;
width: 58px;
height: 190px;
flex-direction: column;
gap: 8px;
padding: 10px 8px;
border: 1px solid var(--line);
background: var(--panel-raised);
box-shadow: 0 14px 35px rgb(0 0 0 / 55%);
}
.volume-control.open .volume-slider {
display: flex;
}
.volume-slider input[type="range"] {
width: 30px;
height: 125px;
writing-mode: vertical-lr;
direction: rtl;
}
.volume-slider output {
width: auto;
}
+162
View File
@@ -0,0 +1,162 @@
(() => {
"use strict";
const translations = {
en: {
output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT",
rendererLocal: "LOCAL", defaultPlaylist: "Default",
playbackControls: "Playback controls", previousTrack: "Previous track",
playOrPause: "Play or pause", play: "Play", pause: "Pause", stop: "Stop",
nextTrack: "Next track", playbackPosition: "Playback position",
repeatMode: "Repeat mode", setVolume: "Set volume", volume: "Volume",
musicLibrary: "MUSIC LIBRARY", upFolder: "Up one folder", currentFolder: "Current folder",
noLibraryConfigured: "No library configured.", folderContents: "Folder contents",
nowPlaying: "NOW PLAYING", nothingSelected: "Nothing selected yet",
addTrackHint: "Add a track from the library", playlists: "Playlists",
newPlaylist: "New playlist", playlist: "PLAYLIST", clearList: "CLEAR LIST",
emptyPlaylist: "This playlist is empty.", emptyPlaylistHint: "Use next to a track or folder.",
title: "TITLE", album: "ALBUM", duration: "DURATION", actions: "Actions",
connecting: "Connecting…", signIn: "Sign in", username: "Username",
password: "Password", noLibrary: "No library", playNow: "Play {name} now",
addNamed: "Add {name}", loadingNamed: "Loading {name}…", addingNamed: "Adding {name}…",
playlistName: "Playlist name", removePlaylist: "Remove playlist",
removeNamed: "Remove {name}", oneTrack: "track", manyTracks: "tracks",
repeatOff: "Repeat off", repeatAll: "Repeat all", repeatOne: "Repeat one track",
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",
},
nl: {
output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
rendererLocal: "LOKAAL", defaultPlaylist: "Standaard",
playbackControls: "Afspeelbediening", previousTrack: "Vorige track",
playOrPause: "Afspelen of pauzeren", play: "Afspelen", pause: "Pauzeren", stop: "Stoppen",
nextTrack: "Volgende track", playbackPosition: "Afspeelpositie",
repeatMode: "Herhaalmodus", setVolume: "Volume instellen", volume: "Volume",
musicLibrary: "MUZIEKBIBLIOTHEEK", upFolder: "Eén map omhoog", currentFolder: "Huidige map",
noLibraryConfigured: "Geen bibliotheek geconfigureerd.", folderContents: "Mapinhoud",
nowPlaying: "NU AAN HET SPELEN", nothingSelected: "Nog niets gekozen",
addTrackHint: "Voeg een track toe vanuit de bibliotheek", playlists: "Afspeellijsten",
newPlaylist: "Nieuwe afspeellijst", playlist: "AFSPEELLIJST", clearList: "LIJST WISSEN",
emptyPlaylist: "Deze afspeellijst is leeg.", emptyPlaylistHint: "Gebruik bij een track of map.",
title: "TITEL", album: "ALBUM", duration: "DUUR", actions: "Acties",
connecting: "Verbinden…", signIn: "Aanmelden", username: "Gebruikersnaam",
password: "Wachtwoord", noLibrary: "Geen bibliotheek", playNow: "{name} nu afspelen",
addNamed: "{name} toevoegen", loadingNamed: "{name} laden…", addingNamed: "{name} toevoegen…",
playlistName: "Naam van de afspeellijst", removePlaylist: "Afspeellijst verwijderen",
removeNamed: "{name} verwijderen", oneTrack: "track", manyTracks: "tracks",
repeatOff: "Herhalen uit", repeatAll: "Alles herhalen", repeatOne: "Eén track herhalen",
channels: "kanalen", oneChannel: "kanaal", searchingPlayers: "Netwerkspelers zoeken…",
authUnknown: "Authenticatiestatus onbekend: {message}", noConnection: "Geen verbinding: {message}",
loggedOut: "Je bent uitgelogd.", volumePercent: "Volume instellen, {value} procent",
},
de: {
output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
playbackControls: "Wiedergabesteuerung", previousTrack: "Vorheriger Titel",
playOrPause: "Wiedergeben oder pausieren", play: "Wiedergeben", pause: "Pausieren", stop: "Stoppen",
nextTrack: "Nächster Titel", playbackPosition: "Wiedergabeposition",
repeatMode: "Wiederholungsmodus", setVolume: "Lautstärke einstellen", volume: "Lautstärke",
musicLibrary: "MUSIKBIBLIOTHEK", upFolder: "Einen Ordner nach oben", currentFolder: "Aktueller Ordner",
noLibraryConfigured: "Keine Bibliothek konfiguriert.", folderContents: "Ordnerinhalt",
nowPlaying: "AKTUELLE WIEDERGABE", nothingSelected: "Noch nichts ausgewählt",
addTrackHint: "Titel aus der Bibliothek hinzufügen", playlists: "Wiedergabelisten",
newPlaylist: "Neue Wiedergabeliste", playlist: "WIEDERGABELISTE", clearList: "LISTE LEEREN",
emptyPlaylist: "Diese Wiedergabeliste ist leer.", emptyPlaylistHint: "Verwenden Sie neben einem Titel oder Ordner.",
title: "TITEL", album: "ALBUM", duration: "DAUER", actions: "Aktionen",
connecting: "Verbinden…", signIn: "Anmelden", username: "Benutzername",
password: "Passwort", noLibrary: "Keine Bibliothek", playNow: "{name} jetzt wiedergeben",
addNamed: "{name} hinzufügen", loadingNamed: "{name} wird geladen…", addingNamed: "{name} wird hinzugefügt…",
playlistName: "Name der Wiedergabeliste", removePlaylist: "Wiedergabeliste entfernen",
removeNamed: "{name} entfernen", oneTrack: "Titel", manyTracks: "Titel",
repeatOff: "Wiederholung aus", repeatAll: "Alles wiederholen", repeatOne: "Einen Titel wiederholen",
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",
},
fr: {
output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
rendererLocal: "LOCAL", defaultPlaylist: "Par défaut",
playbackControls: "Commandes de lecture", previousTrack: "Piste précédente",
playOrPause: "Lire ou mettre en pause", play: "Lire", pause: "Pause", stop: "Arrêter",
nextTrack: "Piste suivante", playbackPosition: "Position de lecture",
repeatMode: "Mode répétition", setVolume: "Régler le volume", volume: "Volume",
musicLibrary: "BIBLIOTHÈQUE MUSICALE", upFolder: "Dossier parent", currentFolder: "Dossier actuel",
noLibraryConfigured: "Aucune bibliothèque configurée.", folderContents: "Contenu du dossier",
nowPlaying: "LECTURE EN COURS", nothingSelected: "Aucune sélection",
addTrackHint: "Ajoutez une piste depuis la bibliothèque", playlists: "Listes de lecture",
newPlaylist: "Nouvelle liste de lecture", playlist: "LISTE DE LECTURE", clearList: "VIDER LA LISTE",
emptyPlaylist: "Cette liste de lecture est vide.", emptyPlaylistHint: "Utilisez à côté dune piste ou dun dossier.",
title: "TITRE", album: "ALBUM", duration: "DURÉE", actions: "Actions",
connecting: "Connexion…", signIn: "Se connecter", username: "Nom dutilisateur",
password: "Mot de passe", noLibrary: "Aucune bibliothèque", playNow: "Lire {name} maintenant",
addNamed: "Ajouter {name}", loadingNamed: "Chargement de {name}…", addingNamed: "Ajout de {name}…",
playlistName: "Nom de la liste de lecture", removePlaylist: "Supprimer la liste de lecture",
removeNamed: "Supprimer {name}", oneTrack: "piste", manyTracks: "pistes",
repeatOff: "Répétition désactivée", repeatAll: "Tout répéter", repeatOne: "Répéter une piste",
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",
},
es: {
output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada",
playbackControls: "Controles de reproducción", previousTrack: "Pista anterior",
playOrPause: "Reproducir o pausar", play: "Reproducir", pause: "Pausar", stop: "Detener",
nextTrack: "Pista siguiente", playbackPosition: "Posición de reproducción",
repeatMode: "Modo de repetición", setVolume: "Ajustar volumen", volume: "Volumen",
musicLibrary: "BIBLIOTECA MUSICAL", upFolder: "Subir una carpeta", currentFolder: "Carpeta actual",
noLibraryConfigured: "No hay ninguna biblioteca configurada.", folderContents: "Contenido de la carpeta",
nowPlaying: "REPRODUCIENDO", nothingSelected: "Nada seleccionado",
addTrackHint: "Añade una pista desde la biblioteca", playlists: "Listas de reproducción",
newPlaylist: "Nueva lista de reproducción", playlist: "LISTA DE REPRODUCCIÓN", clearList: "VACIAR LISTA",
emptyPlaylist: "Esta lista de reproducción está vacía.", emptyPlaylistHint: "Usa junto a una pista o carpeta.",
title: "TÍTULO", album: "ÁLBUM", duration: "DURACIÓN", actions: "Acciones",
connecting: "Conectando…", signIn: "Iniciar sesión", username: "Nombre de usuario",
password: "Contraseña", noLibrary: "Sin biblioteca", playNow: "Reproducir {name} ahora",
addNamed: "Añadir {name}", loadingNamed: "Cargando {name}…", addingNamed: "Añadiendo {name}…",
playlistName: "Nombre de la lista de reproducción", removePlaylist: "Eliminar lista de reproducción",
removeNamed: "Eliminar {name}", oneTrack: "pista", manyTracks: "pistas",
repeatOff: "Repetición desactivada", repeatAll: "Repetir todo", repeatOne: "Repetir una pista",
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",
},
};
const supported = Object.keys(translations);
const requested = [...(navigator.languages || [navigator.language])]
.filter(Boolean)
.map((value) => value.toLowerCase().split("-")[0]);
let language = requested.find((value) => supported.includes(value)) || "en";
function t(key, values = {}) {
const template = translations[language][key] ?? translations.en[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_match, name) => values[name] ?? `{${name}}`);
}
function apply(root = document) {
document.documentElement.lang = language;
root.querySelectorAll("[data-i18n]").forEach((element) => {
element.textContent = t(element.dataset.i18n);
});
root.querySelectorAll("[data-i18n-title]").forEach((element) => {
const value = t(element.dataset.i18nTitle);
element.title = value;
element.setAttribute("aria-label", value);
});
root.querySelectorAll("[data-i18n-aria]").forEach((element) => {
element.setAttribute("aria-label", t(element.dataset.i18nAria));
});
}
function setLanguage(value) {
if (!supported.includes(value)) return;
language = value;
apply();
window.dispatchEvent(new CustomEvent("rkt-language-change"));
}
window.RktTranslate = { apply, language: () => language, setLanguage, supported, t };
window.addEventListener("DOMContentLoaded", () => apply());
})();