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);
+29 -13
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
{
"private": true,
"type": "module"
}
+40
View File
@@ -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;
}
}
+124
View File
@@ -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
View File
@@ -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;
}
+70
View File
@@ -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 longlet",
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ð",