ok
This commit is contained in:
+39
-14
@@ -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);
|
||||
|
||||
|
||||
+29
-13
@@ -56,20 +56,33 @@
|
||||
|
||||
<section class="workspace">
|
||||
<aside class="left-pane">
|
||||
<section class="library-pane panel">
|
||||
<div class="panel-header library-header">
|
||||
<div>
|
||||
<span class="panel-kicker" data-i18n="musicLibrary">MUSIC LIBRARY</span>
|
||||
<select id="library" data-i18n-aria="musicLibrary" aria-label="Music library"></select>
|
||||
<section id="library-pane" class="library-pane panel">
|
||||
<div class="library-tabs" role="tablist" data-i18n-aria="musicLibrary" aria-label="Music library">
|
||||
<button id="folders-tab" class="library-tab active" type="button" role="tab" aria-selected="true" aria-controls="folders-panel" data-i18n="folders">Folders</button>
|
||||
<button id="saved-playlists-tab" class="library-tab" type="button" role="tab" aria-selected="false" aria-controls="saved-playlists-panel" tabindex="-1" data-i18n="playlists">Playlists</button>
|
||||
</div>
|
||||
<div id="folders-panel" class="library-folders" role="tabpanel" aria-labelledby="folders-tab">
|
||||
<div class="panel-header library-header">
|
||||
<div>
|
||||
<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" data-i18n-title="upFolder" title="Up one folder" aria-label="Up one folder">↑</button>
|
||||
</div>
|
||||
<button id="library-up" class="square-button" type="button" data-i18n-title="upFolder" title="Up one folder" aria-label="Up one folder">↑</button>
|
||||
<nav id="breadcrumb" class="breadcrumb" data-i18n-aria="currentFolder" aria-label="Current folder"></nav>
|
||||
<div id="library-empty" class="empty-state" hidden>
|
||||
<p data-i18n="noLibraryConfigured">No library configured.</p>
|
||||
<code>[libraries] muziek=D:\Muziek</code>
|
||||
</div>
|
||||
<ul id="library-entries" class="library-list" data-i18n-aria="folderContents" aria-label="Folder contents"></ul>
|
||||
</div>
|
||||
<nav id="breadcrumb" class="breadcrumb" data-i18n-aria="currentFolder" aria-label="Current folder"></nav>
|
||||
<div id="library-empty" class="empty-state" hidden>
|
||||
<p data-i18n="noLibraryConfigured">No library configured.</p>
|
||||
<code>[libraries] muziek=D:\Muziek</code>
|
||||
<div id="saved-playlists-panel" class="library-saved" role="tabpanel" aria-labelledby="saved-playlists-tab" hidden>
|
||||
<div id="saved-playlists-empty" class="empty-state">
|
||||
<p data-i18n="noSavedPlaylists">No saved playlists yet.</p>
|
||||
<span data-i18n="savePlaylistHint">Save a playlist tab to find it here.</span>
|
||||
</div>
|
||||
<ul id="saved-playlists" class="library-list" data-i18n-aria="playlists" aria-label="Playlists"></ul>
|
||||
</div>
|
||||
<ul id="library-entries" class="library-list" data-i18n-aria="folderContents" aria-label="Folder contents"></ul>
|
||||
</section>
|
||||
|
||||
<section class="now-playing-pane panel">
|
||||
@@ -99,7 +112,10 @@
|
||||
<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" data-i18n="clearList">CLEAR LIST</button>
|
||||
<div class="playlist-actions">
|
||||
<button id="playlist-save" class="text-button" type="button" data-i18n="savePlaylist">SAVE PLAYLIST</button>
|
||||
<button id="playlist-clear" class="text-button" type="button" data-i18n="clearList">CLEAR LIST</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playlist-empty" class="empty-state playlist-empty">
|
||||
@@ -151,6 +167,6 @@
|
||||
</section>
|
||||
|
||||
<script src="/translate.js" defer></script>
|
||||
<script src="/app.js" defer></script>
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Keeps the active playlist in memory between player-state requests.
|
||||
* State, command and discovery requests share a queue so responses cannot
|
||||
* overwrite newer state or reuse tracks from a different playlist version.
|
||||
*/
|
||||
export class PlayerStateClient {
|
||||
#sendRequest;
|
||||
#snapshot = null;
|
||||
#pending = Promise.resolve();
|
||||
|
||||
// sendRequest performs the application's JSON GET/POST transport.
|
||||
constructor(sendRequest) {
|
||||
this.#sendRequest = sendRequest;
|
||||
}
|
||||
|
||||
// Queues a state-producing request and returns a complete browser snapshot.
|
||||
// A failed request does not prevent subsequent requests from being sent.
|
||||
request(path, body) {
|
||||
const result = this.#pending.then(() => this.#read(path, body));
|
||||
this.#pending = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sends the cached version and restores omitted tracks from that exact version.
|
||||
async #read(path, body) {
|
||||
if (this.#snapshot) {
|
||||
path += `?playlistVersion=${encodeURIComponent(this.#snapshot.playlistVersion)}`;
|
||||
}
|
||||
const nextState = await this.#sendRequest(path, body);
|
||||
if (nextState.tracks === null) {
|
||||
if (!this.#snapshot || nextState.playlistVersion !== this.#snapshot.playlistVersion) {
|
||||
this.#snapshot = null;
|
||||
throw new Error("Playlist cache does not match the server version.");
|
||||
}
|
||||
nextState.tracks = this.#snapshot.tracks;
|
||||
}
|
||||
this.#snapshot = nextState;
|
||||
return nextState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Library view for the Folders and Playlists tabs. Saved playlists are server
|
||||
* objects: actions address their UUID and reveal their existing playlist tab.
|
||||
* Only the selected library view and highlighted row are local UI state.
|
||||
*/
|
||||
export class PlaylistLibrary {
|
||||
#tabs;
|
||||
#panels;
|
||||
#list;
|
||||
#empty;
|
||||
#command;
|
||||
#formatDuration;
|
||||
#selectedId = null;
|
||||
#signature = null;
|
||||
|
||||
// root owns both library panels; command sends server actions and renders state.
|
||||
constructor(root, command, formatDuration) {
|
||||
this.#tabs = [root.querySelector("#folders-tab"), root.querySelector("#saved-playlists-tab")];
|
||||
this.#panels = [root.querySelector("#folders-panel"), root.querySelector("#saved-playlists-panel")];
|
||||
this.#list = root.querySelector("#saved-playlists");
|
||||
this.#empty = root.querySelector("#saved-playlists-empty");
|
||||
this.#command = command;
|
||||
this.#formatDuration = formatDuration;
|
||||
this.#tabs.forEach((tab, index) => {
|
||||
tab.addEventListener("click", () => this.show(index));
|
||||
tab.addEventListener("keydown", (event) => {
|
||||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
|
||||
this.show(next);
|
||||
this.#tabs[next].focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Switch the library panel without changing the selected playlist or playback.
|
||||
show(index) {
|
||||
this.#tabs.forEach((tab, current) => {
|
||||
const active = current === index;
|
||||
tab.classList.toggle("active", active);
|
||||
tab.setAttribute("aria-selected", String(active));
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
this.#panels[current].hidden = !active;
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh saved playlist summaries only when metadata or language changes.
|
||||
render(playlists) {
|
||||
const signature = JSON.stringify([window.RktTranslate.language(), playlists]);
|
||||
if (signature === this.#signature) return;
|
||||
if (!playlists.some((playlist) => playlist.id === this.#selectedId)) this.#selectedId = null;
|
||||
this.#list.replaceChildren(...playlists.map((playlist) => this.#createEntry(playlist)));
|
||||
this.#empty.hidden = playlists.length > 0;
|
||||
this.#signature = signature;
|
||||
this.#select(this.#selectedId);
|
||||
}
|
||||
|
||||
#select(id) {
|
||||
this.#selectedId = id;
|
||||
for (const row of this.#list.children) {
|
||||
const selected = row.dataset.id === id;
|
||||
row.classList.toggle("selected", selected);
|
||||
row.setAttribute("aria-current", selected ? "true" : "false");
|
||||
}
|
||||
}
|
||||
|
||||
// Each action reveals this playlist by UUID; repeated opening is idempotent.
|
||||
#action(label, title, playlist, play) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "entry-action";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
button.disabled = play && playlist.count === 0;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
this.#select(playlist.id);
|
||||
this.#command(play ? "playlist-play" : "playlist-open", { id: playlist.id });
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
#createEntry(playlist) {
|
||||
const { t } = window.RktTranslate;
|
||||
const row = document.createElement("li");
|
||||
row.className = "library-entry";
|
||||
row.dataset.id = playlist.id;
|
||||
row.tabIndex = 0;
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "entry-icon";
|
||||
icon.textContent = "♫";
|
||||
const name = document.createElement("span");
|
||||
name.className = "entry-name";
|
||||
name.textContent = playlist.name;
|
||||
name.title = playlist.name;
|
||||
const details = document.createElement("small");
|
||||
details.className = "entry-details";
|
||||
details.textContent = `${playlist.count} ${t(playlist.count === 1 ? "oneTrack" : "manyTracks")}, ${this.#formatDuration(playlist.duration)}`;
|
||||
name.append(details);
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "entry-actions";
|
||||
actions.append(
|
||||
this.#action("▶", t("playNow", { name: playlist.name }), playlist, true),
|
||||
this.#action("+", t("openPlaylistNamed", { name: playlist.name }), playlist, false),
|
||||
);
|
||||
row.append(icon, name, actions);
|
||||
row.addEventListener("click", () => this.#select(playlist.id));
|
||||
row.addEventListener("dblclick", (event) => {
|
||||
if (event.target.closest("button")) return;
|
||||
this.#command("playlist-open", { id: playlist.id });
|
||||
});
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.target !== row) return;
|
||||
if (["Enter", "+", " "].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
this.#select(playlist.id);
|
||||
if (event.key !== " ") this.#command("playlist-open", { id: playlist.id });
|
||||
}
|
||||
});
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+61
-2
@@ -332,10 +332,61 @@ input[type="range"] {
|
||||
|
||||
.library-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.library-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.library-tab {
|
||||
flex: 1;
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.library-tab.active {
|
||||
color: var(--accent);
|
||||
box-shadow: inset 0 -2px var(--accent);
|
||||
}
|
||||
|
||||
.library-folders {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.library-saved {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.library-folders[hidden],
|
||||
.library-saved[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-saved > .library-list,
|
||||
.library-saved > .empty-state {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.library-entry.selected {
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
.entry-details {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
min-height: 55px;
|
||||
padding: 8px 10px;
|
||||
@@ -601,6 +652,8 @@ input[type="range"] {
|
||||
|
||||
.playlist-toolbar {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-height: 53px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
@@ -611,6 +664,12 @@ input[type="range"] {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.playlist-toolbar > .playlist-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.playlist-toolbar strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -739,7 +798,7 @@ input[type="range"] {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.library-pane > .empty-state {
|
||||
.library-folders > .empty-state {
|
||||
grid-row: 3;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
folders: "Folders",
|
||||
savePlaylist: "SAVE PLAYLIST",
|
||||
playlistSaved: "SAVED",
|
||||
noSavedPlaylists: "No saved playlists yet.",
|
||||
savePlaylistHint: "Save a playlist tab to find it here.",
|
||||
openPlaylistNamed: "Open {name} as a tab",
|
||||
closePlaylist: "Close tab",
|
||||
playingTime: "{duration} playing time",
|
||||
output: "OUTPUT", searchPlayers: "Search for network players", logout: "LOG OUT",
|
||||
rendererLocal: "LOCAL", defaultPlaylist: "Default",
|
||||
@@ -34,6 +41,13 @@
|
||||
"dlna-renderer-unreachable": "The DLNA renderer is unreachable.",
|
||||
},
|
||||
nl: {
|
||||
folders: "Mappen",
|
||||
savePlaylist: "AFSPEELLIJST OPSLAAN",
|
||||
playlistSaved: "OPGESLAGEN",
|
||||
noSavedPlaylists: "Nog geen opgeslagen afspeellijsten.",
|
||||
savePlaylistHint: "Sla een playlisttab op om hem hier terug te vinden.",
|
||||
openPlaylistNamed: "{name} als tab openen",
|
||||
closePlaylist: "Tab sluiten",
|
||||
playingTime: "{duration} speeltijd",
|
||||
output: "UITVOER", searchPlayers: "Netwerkspelers zoeken", logout: "UITLOGGEN",
|
||||
rendererLocal: "LOKAAL", defaultPlaylist: "Standaard",
|
||||
@@ -65,6 +79,13 @@
|
||||
"dlna-renderer-unreachable": "De DLNA-renderer is niet bereikbaar.",
|
||||
},
|
||||
de: {
|
||||
folders: "Ordner",
|
||||
savePlaylist: "WIEDERGABELISTE SPEICHERN",
|
||||
playlistSaved: "GESPEICHERT",
|
||||
noSavedPlaylists: "Noch keine gespeicherten Wiedergabelisten.",
|
||||
savePlaylistHint: "Speichern Sie eine Wiedergabeliste, um sie hier zu finden.",
|
||||
openPlaylistNamed: "{name} als Tab öffnen",
|
||||
closePlaylist: "Tab schließen",
|
||||
playingTime: "{duration} Spielzeit",
|
||||
output: "AUSGABE", searchPlayers: "Netzwerkplayer suchen", logout: "ABMELDEN",
|
||||
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
||||
@@ -96,6 +117,13 @@
|
||||
"dlna-renderer-unreachable": "Der DLNA-Renderer ist nicht erreichbar.",
|
||||
},
|
||||
fr: {
|
||||
folders: "Dossiers",
|
||||
savePlaylist: "ENREGISTRER LA LISTE",
|
||||
playlistSaved: "ENREGISTRÉE",
|
||||
noSavedPlaylists: "Aucune liste de lecture enregistrée.",
|
||||
savePlaylistHint: "Enregistrez une liste de lecture pour la retrouver ici.",
|
||||
openPlaylistNamed: "Ouvrir {name} dans un onglet",
|
||||
closePlaylist: "Fermer l’onglet",
|
||||
playingTime: "{duration} de lecture",
|
||||
output: "SORTIE", searchPlayers: "Rechercher les lecteurs réseau", logout: "DÉCONNEXION",
|
||||
rendererLocal: "LOCAL", defaultPlaylist: "Par défaut",
|
||||
@@ -127,6 +155,13 @@
|
||||
"dlna-renderer-unreachable": "Le lecteur DLNA est inaccessible.",
|
||||
},
|
||||
es: {
|
||||
folders: "Carpetas",
|
||||
savePlaylist: "GUARDAR LISTA",
|
||||
playlistSaved: "GUARDADA",
|
||||
noSavedPlaylists: "Todavía no hay listas guardadas.",
|
||||
savePlaylistHint: "Guarda una lista de reproducción para encontrarla aquí.",
|
||||
openPlaylistNamed: "Abrir {name} en una pestaña",
|
||||
closePlaylist: "Cerrar pestaña",
|
||||
playingTime: "{duration} de reproducción",
|
||||
output: "SALIDA", searchPlayers: "Buscar reproductores de red", logout: "CERRAR SESIÓN",
|
||||
rendererLocal: "LOCAL", defaultPlaylist: "Predeterminada",
|
||||
@@ -158,6 +193,13 @@
|
||||
"dlna-renderer-unreachable": "No se puede acceder al renderizador DLNA.",
|
||||
},
|
||||
it: {
|
||||
folders: "Cartelle",
|
||||
savePlaylist: "SALVA PLAYLIST",
|
||||
playlistSaved: "SALVATA",
|
||||
noSavedPlaylists: "Nessuna playlist salvata.",
|
||||
savePlaylistHint: "Salva una playlist per ritrovarla qui.",
|
||||
openPlaylistNamed: "Apri {name} in una scheda",
|
||||
closePlaylist: "Chiudi scheda",
|
||||
playingTime: "{duration} di riproduzione",
|
||||
output: "USCITA", searchPlayers: "Cerca lettori di rete", logout: "ESCI",
|
||||
rendererLocal: "LOCALE", defaultPlaylist: "Predefinita",
|
||||
@@ -189,6 +231,13 @@
|
||||
"dlna-renderer-unreachable": "Il renderer DLNA non è raggiungibile.",
|
||||
},
|
||||
sv: {
|
||||
folders: "Mappar",
|
||||
savePlaylist: "SPARA SPELLISTA",
|
||||
playlistSaved: "SPARAD",
|
||||
noSavedPlaylists: "Inga sparade spellistor ännu.",
|
||||
savePlaylistHint: "Spara en spellista för att hitta den här.",
|
||||
openPlaylistNamed: "Öppna {name} som en flik",
|
||||
closePlaylist: "Stäng fliken",
|
||||
playingTime: "{duration} speltid",
|
||||
output: "UTGÅNG", searchPlayers: "Sök efter nätverksspelare", logout: "LOGGA UT",
|
||||
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
||||
@@ -220,6 +269,13 @@
|
||||
"dlna-renderer-unreachable": "DLNA-renderaren kan inte nås.",
|
||||
},
|
||||
no: {
|
||||
folders: "Mapper",
|
||||
savePlaylist: "LAGRE SPILLELISTE",
|
||||
playlistSaved: "LAGRET",
|
||||
noSavedPlaylists: "Ingen lagrede spillelister ennå.",
|
||||
savePlaylistHint: "Lagre en spilleliste for å finne den her.",
|
||||
openPlaylistNamed: "Åpne {name} som en fane",
|
||||
closePlaylist: "Lukk fanen",
|
||||
playingTime: "{duration} spilletid",
|
||||
output: "UTGANG", searchPlayers: "Søk etter nettverksspillere", logout: "LOGG UT",
|
||||
rendererLocal: "LOKAL", defaultPlaylist: "Standard",
|
||||
@@ -251,6 +307,13 @@
|
||||
"dlna-renderer-unreachable": "DLNA-gjengiveren kan ikke nås.",
|
||||
},
|
||||
fi: {
|
||||
folders: "Kansiot",
|
||||
savePlaylist: "TALLENNA SOITTOLISTA",
|
||||
playlistSaved: "TALLENNETTU",
|
||||
noSavedPlaylists: "Ei vielä tallennettuja soittolistoja.",
|
||||
savePlaylistHint: "Tallenna soittolista, niin löydät sen täältä.",
|
||||
openPlaylistNamed: "Avaa {name} välilehtenä",
|
||||
closePlaylist: "Sulje välilehti",
|
||||
playingTime: "Toistoaika: {duration}",
|
||||
output: "ULOSTULO", searchPlayers: "Etsi verkkosoittimia", logout: "KIRJAUDU ULOS",
|
||||
rendererLocal: "PAIKALLINEN", defaultPlaylist: "Oletus",
|
||||
@@ -282,6 +345,13 @@
|
||||
"dlna-renderer-unreachable": "DLNA-toistimeen ei saada yhteyttä.",
|
||||
},
|
||||
is: {
|
||||
folders: "Möppur",
|
||||
savePlaylist: "VISTA SPILUNARLISTA",
|
||||
playlistSaved: "VISTAÐ",
|
||||
noSavedPlaylists: "Engir vistaðir spilunarlistar enn.",
|
||||
savePlaylistHint: "Vistaðu spilunarlista til að finna hann hér.",
|
||||
openPlaylistNamed: "Opna {name} í flipa",
|
||||
closePlaylist: "Loka flipa",
|
||||
playingTime: "Spilunartími: {duration}",
|
||||
output: "ÚTTAK", searchPlayers: "Leita að netspilurum", logout: "SKRÁ ÚT",
|
||||
rendererLocal: "STAÐBUNDIÐ", defaultPlaylist: "Sjálfgefið",
|
||||
|
||||
Reference in New Issue
Block a user