Initial import of rkt-web-player
This commit is contained in:
+424
@@ -0,0 +1,424 @@
|
||||
const elements = {
|
||||
renderer: document.querySelector("#renderer"),
|
||||
discover: document.querySelector("#discover"),
|
||||
previous: document.querySelector("#previous"),
|
||||
play: document.querySelector("#play"),
|
||||
stop: document.querySelector("#stop"),
|
||||
next: document.querySelector("#next"),
|
||||
seek: document.querySelector("#seek"),
|
||||
position: document.querySelector("#position"),
|
||||
duration: document.querySelector("#duration"),
|
||||
repeat: document.querySelector("#repeat"),
|
||||
volume: document.querySelector("#volume"),
|
||||
volumeValue: document.querySelector("#volume-value"),
|
||||
library: document.querySelector("#library"),
|
||||
libraryUp: document.querySelector("#library-up"),
|
||||
breadcrumb: document.querySelector("#breadcrumb"),
|
||||
libraryEmpty: document.querySelector("#library-empty"),
|
||||
libraryEntries: document.querySelector("#library-entries"),
|
||||
title: document.querySelector("#now-title"),
|
||||
meta: document.querySelector("#now-meta"),
|
||||
tabs: document.querySelector("#tabs"),
|
||||
tabAdd: document.querySelector("#tab-add"),
|
||||
count: document.querySelector("#track-count"),
|
||||
playlistClear: document.querySelector("#playlist-clear"),
|
||||
playlistEmpty: document.querySelector("#playlist-empty"),
|
||||
playlist: document.querySelector("#playlist"),
|
||||
bits: document.querySelector("#audio-bits"),
|
||||
rate: document.querySelector("#audio-rate"),
|
||||
channels: document.querySelector("#audio-channels"),
|
||||
format: document.querySelector("#audio-format"),
|
||||
source: document.querySelector("#audio-source"),
|
||||
status: document.querySelector("#status"),
|
||||
};
|
||||
|
||||
let state = null;
|
||||
let seekBusy = false;
|
||||
let draggedTrack = null;
|
||||
let commandBusy = false;
|
||||
|
||||
function formatTime(value) {
|
||||
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");
|
||||
const seconds = (whole % 60).toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
elements.status.textContent = message || "";
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const options = body === undefined
|
||||
? { cache: "no-store" }
|
||||
: {
|
||||
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) throw new Error(data.error || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function command(name, data = {}, pendingMessage = "") {
|
||||
if (pendingMessage) setStatus(pendingMessage);
|
||||
commandBusy = true;
|
||||
try {
|
||||
render(await api(`/api/command/${name}`, data));
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
commandBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
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.dataset.signature = signature;
|
||||
}
|
||||
select.value = selectedId || "";
|
||||
}
|
||||
|
||||
function renderSelectors(nextState) {
|
||||
const rendererItems = nextState.renderers.map((item) => ({
|
||||
id: item.id,
|
||||
label: `${item.name} · ${item.kind.toUpperCase()}`,
|
||||
}));
|
||||
replaceSelect(
|
||||
elements.renderer,
|
||||
rendererItems,
|
||||
nextState.rendererId,
|
||||
rendererItems.map((item) => item.id).join("|"),
|
||||
);
|
||||
|
||||
const libraryItems = nextState.libraries.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
}));
|
||||
replaceSelect(
|
||||
elements.library,
|
||||
libraryItems,
|
||||
nextState.libraryId,
|
||||
libraryItems.map((item) => `${item.id}:${item.label}`).join("|"),
|
||||
);
|
||||
|
||||
elements.discover.classList.toggle("busy", nextState.discovering);
|
||||
elements.discover.disabled = nextState.discovering;
|
||||
elements.library.disabled = nextState.libraries.length === 0;
|
||||
}
|
||||
|
||||
function entryAction(label, title, handler) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "entry-action";
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
handler();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
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.title = elements.breadcrumb.textContent;
|
||||
elements.libraryUp.disabled = !nextState.browser.canGoUp;
|
||||
elements.libraryEmpty.hidden = nextState.libraries.length > 0;
|
||||
|
||||
const signature = nextState.browser.entries
|
||||
.map((entry) => `${entry.index}:${entry.kind}:${entry.name}`)
|
||||
.join("|");
|
||||
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("▶", `${entry.name} nu afspelen`, () => {
|
||||
command("item-play", { index: entry.index }, `${entry.name} laden…`);
|
||||
}),
|
||||
entryAction("+", `${entry.name} toevoegen`, () => {
|
||||
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
|
||||
}),
|
||||
);
|
||||
|
||||
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 }, `${entry.name} laden…`);
|
||||
});
|
||||
}
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
command(
|
||||
entry.kind === "container" ? "browse" : "item-play",
|
||||
{ index: entry.index },
|
||||
entry.kind === "track" ? `${entry.name} laden…` : "",
|
||||
);
|
||||
} else if (event.key === "+") {
|
||||
command("item-add", { index: entry.index }, `${entry.name} toevoegen…`);
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
elements.libraryEntries.replaceChildren(...rows);
|
||||
elements.libraryEntries.dataset.signature = signature;
|
||||
}
|
||||
|
||||
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;
|
||||
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("Naam van de afspeellijst", 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 = "Afspeellijst verwijderen";
|
||||
remove.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
command("tab-delete", { index: tab.index });
|
||||
});
|
||||
button.append(remove);
|
||||
}
|
||||
return button;
|
||||
});
|
||||
elements.tabs.replaceChildren(...tabs);
|
||||
elements.tabs.dataset.signature = signature;
|
||||
}
|
||||
|
||||
for (const tab of elements.tabs.children) {
|
||||
const active = Number(tab.dataset.index) === nextState.currentTab;
|
||||
tab.classList.toggle("active", active);
|
||||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||
}
|
||||
}
|
||||
|
||||
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 = `${track.title} verwijderen`;
|
||||
remove.setAttribute("aria-label", `${track.title} verwijderen`);
|
||||
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;
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
elements.playlistEmpty.hidden = nextState.tracks.length > 0;
|
||||
elements.count.textContent = `${nextState.tracks.length} ${nextState.tracks.length === 1 ? "track" : "tracks"}`;
|
||||
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 : "Nog niets gekozen";
|
||||
elements.meta.textContent = current
|
||||
? [current.artist, current.album].filter(Boolean).join(" · ") || current.source
|
||||
: "Voeg een track toe vanuit de bibliotheek";
|
||||
|
||||
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.previous.disabled = nextState.tracks.length === 0;
|
||||
elements.next.disabled = nextState.tracks.length === 0;
|
||||
elements.stop.disabled = nextState.state === "stopped";
|
||||
|
||||
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;
|
||||
}
|
||||
elements.seek.disabled = !nextState.duration;
|
||||
|
||||
elements.volume.value = nextState.volume;
|
||||
elements.volumeValue.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" };
|
||||
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.format.textContent = nextState.format || "—";
|
||||
elements.source.textContent = nextState.source || "—";
|
||||
}
|
||||
|
||||
function render(nextState) {
|
||||
state = nextState;
|
||||
renderSelectors(nextState);
|
||||
renderBrowser(nextState);
|
||||
renderTabs(nextState);
|
||||
renderPlaylist(nextState);
|
||||
renderPlayer(nextState);
|
||||
setStatus(nextState.error || (nextState.discovering ? "Netwerkspelers zoeken…" : ""));
|
||||
}
|
||||
|
||||
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.discover.addEventListener("click", async () => {
|
||||
try {
|
||||
render(await api("/api/discover", {}));
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
});
|
||||
elements.volume.addEventListener("input", () => {
|
||||
elements.volumeValue.value = `${elements.volume.value}%`;
|
||||
});
|
||||
elements.volume.addEventListener("change", () => command("volume", { value: Number(elements.volume.value) }));
|
||||
elements.seek.addEventListener("pointerdown", () => { seekBusy = true; });
|
||||
elements.seek.addEventListener("change", () => {
|
||||
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"));
|
||||
|
||||
async function refresh() {
|
||||
if (commandBusy) return;
|
||||
try {
|
||||
render(await api("/api/state"));
|
||||
} catch (error) {
|
||||
setStatus(`Geen verbinding: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 1000);
|
||||
@@ -0,0 +1,131 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#171716">
|
||||
<title>RKT Web Player</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">RKT</span>
|
||||
<span class="brand-name">WEB PLAYER</span>
|
||||
</div>
|
||||
|
||||
<div class="output-control">
|
||||
<label for="renderer">UITVOER</label>
|
||||
<select id="renderer"></select>
|
||||
<button id="discover" class="square-button" type="button" title="Netwerkspelers zoeken" aria-label="Netwerkspelers zoeken">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="transport-bar" aria-label="Afspeelbediening">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="seek-control">
|
||||
<input id="seek" type="range" min="0" max="100" value="0" step="0.1" aria-label="Afspeelpositie">
|
||||
<div class="time-display">
|
||||
<span id="position">00:00:00</span>
|
||||
<span class="time-divider">/</span>
|
||||
<span id="duration">00:00:00</span>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<aside class="left-pane">
|
||||
<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>
|
||||
</div>
|
||||
<button id="library-up" class="square-button" type="button" title="Eén map omhoog" aria-label="Eén map omhoog">↑</button>
|
||||
</div>
|
||||
<nav id="breadcrumb" class="breadcrumb" aria-label="Huidige map"></nav>
|
||||
<div id="library-empty" class="empty-state" hidden>
|
||||
<p>Geen bibliotheek geconfigureerd.</p>
|
||||
<code>[library] paths=D:\Muziek</code>
|
||||
</div>
|
||||
<ul id="library-entries" class="library-list" aria-label="Mapinhoud"></ul>
|
||||
</section>
|
||||
|
||||
<section class="now-playing-pane panel">
|
||||
<div class="cover-art" aria-hidden="true">
|
||||
<div class="record"></div>
|
||||
<span>RKT</span>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<div class="playlist-toolbar">
|
||||
<div>
|
||||
<span class="panel-kicker">AFSPEELLIJST</span>
|
||||
<strong id="track-count">0 tracks</strong>
|
||||
</div>
|
||||
<button id="playlist-clear" class="text-button" type="button">LIJST WISSEN</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>
|
||||
</div>
|
||||
|
||||
<div class="playlist-scroll">
|
||||
<table class="playlist-table">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer class="status-bar">
|
||||
<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-format">—</span>
|
||||
<span id="audio-source">—</span>
|
||||
</div>
|
||||
<p id="status" role="status" aria-live="polite">Verbinden…</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,847 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: #171716;
|
||||
--panel: #20201f;
|
||||
--panel-raised: #292927;
|
||||
--line: #41413e;
|
||||
--line-soft: #30302e;
|
||||
--text: #f4f3ee;
|
||||
--muted: #aaa9a1;
|
||||
--accent: #f3961e;
|
||||
--accent-soft: rgb(243 150 30 / 14%);
|
||||
--danger: #f07067;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 85% 0, rgb(243 150 30 / 7%), transparent 30rem),
|
||||
var(--background);
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: .38;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
min-height: 620px;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header,
|
||||
.transport-bar,
|
||||
.status-bar,
|
||||
.output-control,
|
||||
.volume-control,
|
||||
.tabs-bar,
|
||||
.playlist-toolbar,
|
||||
.library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
justify-content: space-between;
|
||||
min-height: 46px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #171716;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.brand-name,
|
||||
.panel-kicker,
|
||||
.output-control label,
|
||||
.playlist-table th {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .14em;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
letter-spacing: .18em;
|
||||
}
|
||||
|
||||
.output-control {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.output-control label,
|
||||
.panel-kicker,
|
||||
.playlist-table th {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
select,
|
||||
.square-button,
|
||||
.text-button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
select {
|
||||
max-width: 230px;
|
||||
min-height: 34px;
|
||||
padding: 5px 30px 5px 9px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.square-button {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.square-button.busy {
|
||||
animation: spin .9s linear infinite;
|
||||
}
|
||||
|
||||
.transport-bar {
|
||||
min-height: 54px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.transport-buttons {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.command-button {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
min-width: 44px;
|
||||
height: 100%;
|
||||
min-height: 52px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: transparent;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.command-button:hover,
|
||||
.command-button.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.play-button {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.seek-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 1fr) auto;
|
||||
flex: 1;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.time-divider {
|
||||
color: var(--line);
|
||||
}
|
||||
|
||||
.repeat-button {
|
||||
border-right: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.repeat-button[data-repeat="one"]::after {
|
||||
content: "1";
|
||||
position: absolute;
|
||||
margin: 16px 0 0 18px;
|
||||
font-size: 9px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
align-self: stretch;
|
||||
width: 210px;
|
||||
gap: 9px;
|
||||
padding: 0 12px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.volume-control span,
|
||||
.volume-control output {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.volume-control output {
|
||||
width: 32px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 31%) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(230px, 58%) minmax(180px, 42%);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.library-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
min-height: 55px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.library-header {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.library-header > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.library-header select {
|
||||
max-width: 100%;
|
||||
min-height: 30px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
min-height: 31px;
|
||||
padding: 8px 11px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-list {
|
||||
grid-row: 3;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.library-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 25px minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
min-height: 35px;
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.library-entry:hover,
|
||||
.library-entry:focus-within {
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-actions {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.library-entry:hover .entry-actions,
|
||||
.library-entry:focus-within .entry-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.entry-action,
|
||||
.row-action {
|
||||
display: grid;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: #181817;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.entry-action:hover,
|
||||
.row-action:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.now-playing-pane {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 42%) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-art {
|
||||
position: relative;
|
||||
display: grid;
|
||||
aspect-ratio: 1;
|
||||
max-height: 100%;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(140deg, transparent 49.5%, rgb(243 150 30 / 55%) 50%, transparent 50.5%),
|
||||
#2b2b29;
|
||||
}
|
||||
|
||||
.record {
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle, var(--accent) 0 8%, #111 8.5% 13%, transparent 13.5%),
|
||||
repeating-radial-gradient(circle, #2f2f2c 0 2px, #141413 3px 6px);
|
||||
box-shadow: 0 12px 30px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.cover-art > span {
|
||||
position: absolute;
|
||||
color: #111;
|
||||
font-size: 8px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.track-summary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-summary h1 {
|
||||
margin: 8px 0 5px;
|
||||
overflow: hidden;
|
||||
font-size: clamp(18px, 2.1vw, 30px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -.035em;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-summary p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
min-height: 38px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #1b1b1a;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 190px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line-soft);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--panel);
|
||||
color: var(--accent);
|
||||
font-weight: 750;
|
||||
box-shadow: inset 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tab-delete {
|
||||
display: grid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tab-delete:hover {
|
||||
background: rgb(240 112 103 / 18%);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tab-add {
|
||||
align-self: stretch;
|
||||
width: 40px;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--line-soft);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.playlist-toolbar {
|
||||
justify-content: space-between;
|
||||
min-height: 53px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.playlist-toolbar > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.playlist-toolbar strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.playlist-scroll {
|
||||
grid-row: 3;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
padding: 8px 7px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #1b1b1a;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.playlist-table td {
|
||||
padding: 9px 7px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playlist-row {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.playlist-row:hover {
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
.playlist-row.current {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.playlist-row.current .track-title,
|
||||
.playlist-row.current .track-number {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.track-number,
|
||||
.track-album,
|
||||
.track-duration,
|
||||
.track-artist {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.track-title {
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.number-column {
|
||||
width: 43px;
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.duration-column {
|
||||
width: 70px;
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.action-column {
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.playlist-row:hover .row-action,
|
||||
.row-action:focus-visible {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 28px 16px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-state code {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.playlist-empty {
|
||||
grid-row: 3;
|
||||
z-index: 2;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.library-pane > .empty-state {
|
||||
grid-row: 3;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.playlist-empty[hidden],
|
||||
.library-pane .empty-state[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
justify-content: space-between;
|
||||
min-height: 29px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--line);
|
||||
background: #121211;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.audio-status {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.audio-status span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#status {
|
||||
max-width: 46%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--danger);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible,
|
||||
input:focus-visible,
|
||||
.library-entry:focus-visible,
|
||||
.playlist-row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.entry-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.app-shell {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.transport-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.seek-control {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.transport-buttons {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.command-button {
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
grid-template-rows: minmax(330px, 48vh) 190px;
|
||||
}
|
||||
|
||||
.playlist-pane {
|
||||
min-height: 520px;
|
||||
border-top: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.app-shell {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-name,
|
||||
.output-control label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.output-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.output-control select {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.transport-buttons .command-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.repeat-button {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.now-playing-pane {
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.playlist-table th:nth-child(3),
|
||||
.playlist-table td:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.audio-status {
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 11px;
|
||||
}
|
||||
|
||||
#status {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .01ms !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user