Initial import of rkt-web-player

This commit is contained in:
2026-08-26 22:12:52 +02:00
parent e1247719a9
commit d22a7ad529
13 changed files with 3045 additions and 1 deletions
+424
View File
@@ -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);
+131
View File
@@ -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>
+847
View File
@@ -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;
}
}