aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--deckit/modules/music.py19
-rw-r--r--deckit/modules/sbbuilder.py2
-rw-r--r--deckit/server.py25
-rw-r--r--static/app.js12
-rw-r--r--static/deck.css92
-rw-r--r--static/modules/music.js63
-rw-r--r--static/modules/obs.js9
-rw-r--r--static/modules/sbbuilder.js49
-rw-r--r--tests/test_music.py12
-rw-r--r--tests/test_server.py7
10 files changed, 232 insertions, 58 deletions
diff --git a/deckit/modules/music.py b/deckit/modules/music.py
index af13688..f0baacb 100644
--- a/deckit/modules/music.py
+++ b/deckit/modules/music.py
@@ -2,7 +2,7 @@
import subprocess
-from ..module import Module
+from ..module import Module, ModuleError
SEP = "\x1f"
@@ -29,6 +29,7 @@ def _popen(cmd):
def make(cfg: dict) -> Module:
run = cfg.get("run", _run)
+ popen = cfg.get("popen", _popen)
def state():
fmt = SEP.join(["{{status}}", "{{title}}", "{{artist}}", "{{album}}", "{{mpris:length}}",
@@ -40,10 +41,22 @@ def make(cfg: dict) -> Module:
if not out:
return {"status": "Stopped", "volume": volume}
status, title, artist, album, length, pos, art, name = (out.split(SEP) + [""] * 8)[:8]
- art_src = art if art.startswith(("http://", "https://")) else None
+ if art.startswith(("http://", "https://")):
+ art_src = art
+ elif art.startswith("file://"):
+ art_src = "/api/art?v=" + str(abs(hash(art)))
+ else:
+ art_src = None
return {"status": status, "title": title, "artist": artist, "album": album,
"length": int(length or 0) / 1e6, "position": int(pos or 0) / 1e6,
"art": art_src, "player": name.split(".")[0], "volume": volume}
- actions = {name: (lambda cmd: (lambda body: _popen(cmd)))(cmd) for name, cmd in BASE_ACTIONS.items()}
+ def seek(body):
+ pos = body.get("position")
+ if not isinstance(pos, (int, float)) or pos < 0:
+ raise ModuleError("position must be a number")
+ popen(["playerctl", "position", str(round(float(pos), 1))])
+
+ actions = {name: (lambda cmd: (lambda body: popen(cmd)))(cmd) for name, cmd in BASE_ACTIONS.items()}
+ actions["seek"] = seek
return Module(name="music", title="Music", state=state, actions=actions)
diff --git a/deckit/modules/sbbuilder.py b/deckit/modules/sbbuilder.py
index 3a48a1d..41f3a14 100644
--- a/deckit/modules/sbbuilder.py
+++ b/deckit/modules/sbbuilder.py
@@ -33,4 +33,4 @@ def make(cfg: dict) -> Module:
return {"offline": True}
return {"offline": False, "jobs": data.get("jobs", [])}
- return Module(name="sbbuilder", title="Builds", state=state)
+ return Module(name="sbbuilder", title="Build VM", state=state)
diff --git a/deckit/server.py b/deckit/server.py
index bddbb00..638322f 100644
--- a/deckit/server.py
+++ b/deckit/server.py
@@ -11,11 +11,12 @@ import mimetypes
import os
import secrets
import socketserver
+import subprocess
import threading
from http.cookies import CookieError, SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
-from urllib.parse import parse_qs, urlsplit
+from urllib.parse import parse_qs, unquote, urlparse, urlsplit
from . import config, module, theme
@@ -39,6 +40,25 @@ def token_path() -> Path:
return config.user_config_path().parent / "token"
+def _run(cmd, timeout=2):
+ try:
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout.strip()
+ except (OSError, subprocess.TimeoutExpired):
+ return ""
+
+
+def art():
+ """The current track's cover art, if the player exposes a local file."""
+ url = _run(["playerctl", "metadata", "mpris:artUrl"])
+ if not url.startswith("file://"):
+ return None
+ path = Path(unquote(urlparse(url).path))
+ try:
+ return path.read_bytes(), mimetypes.guess_type(path)[0] or "image/png"
+ except OSError:
+ return None
+
+
def load_token() -> str:
"""A per-machine secret the tablet's browser holds as a cookie."""
path = token_path()
@@ -98,6 +118,9 @@ class Handler(BaseHTTPRequestHandler):
if not self._authed():
return self._send(401)
parts = path.strip("/").split("/")
+ if len(parts) == 2 and parts[1] == "art":
+ image = art()
+ return self._send(200, image[0], image[1], cache=True) if image else self._send(404)
if len(parts) == 2 and parts[1] == "modules":
return self._json(REGISTRY.names())
if len(parts) == 2:
diff --git a/static/app.js b/static/app.js
index c6fb06e..c7730e3 100644
--- a/static/app.js
+++ b/static/app.js
@@ -47,13 +47,15 @@ for (const node of loadLayout()) {
function header(title) {
const head = document.createElement("div");
head.className = "panel-head";
+ const dot = document.createElement("span");
+ dot.className = "panel-dot";
const h = document.createElement("h2");
h.textContent = title;
const drag = document.createElement("button");
drag.className = "panel-drag";
- drag.textContent = "≡";
+ drag.textContent = "⠿";
drag.setAttribute("aria-label", "Move panel");
- head.append(h, drag);
+ head.append(dot, h, drag);
return head;
}
@@ -70,11 +72,13 @@ function makeCard(name, title) {
}
const content = document.createElement("div");
content.className = "grid-stack-item-content panel";
- content.append(header(title));
+ const body = document.createElement("div");
+ body.className = "panel-body";
+ content.append(header(title), body);
item.append(content);
gridEl.append(item);
grid.makeWidget(item);
- return content;
+ return body;
}
async function mountModule(name) {
diff --git a/static/deck.css b/static/deck.css
index d6b62dc..5c4af84 100644
--- a/static/deck.css
+++ b/static/deck.css
@@ -22,34 +22,102 @@ body {
.grid-stack { min-height: 100dvh; }
-.panel {
+.panel { min-height: 0; min-width: 0; }
+
+.grid-stack-item-content.panel {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ overflow: hidden;
+ border-radius: 16px;
background: var(--udt-surface0);
border: 1px solid var(--udt-surface1);
- border-radius: 16px;
- padding: 16px;
- min-height: 0;
- min-width: 0;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, .18);
}
-.grid-stack-item-content.panel { position: absolute; }
-
-.panel-head { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; }
+.panel-head {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--udt-mantle);
+ border-bottom: 1px solid var(--udt-surface1);
+}
+.panel-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--udt-accent);
+ flex: none;
+}
.panel .panel-head h2 {
- margin: 0;
flex: 1;
+ margin: 0;
font-size: 13px;
- font-weight: 500;
- color: var(--udt-subtext0);
+ font-weight: 600;
+ letter-spacing: .02em;
+ color: var(--udt-text);
}
.panel-drag {
font: inherit;
color: var(--udt-overlay0);
background: transparent;
border: 0;
- padding: 2px 8px;
+ padding: 2px 6px;
+ border-radius: 6px;
cursor: grab;
touch-action: none;
}
+.panel-drag:hover { color: var(--udt-subtext0); background: var(--udt-surface1); }
+.panel-body { flex: 1; min-height: 0; overflow: auto; padding: 14px; }
+
+.section-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--udt-overlay0);
+ margin: 12px 0 6px;
+}
+.section-label:first-child { margin-top: 0; }
+
+.music .top { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
+.music .art {
+ width: 64px;
+ height: 64px;
+ border-radius: 10px;
+ object-fit: cover;
+ background: var(--udt-surface1);
+ flex: none;
+}
+.music .art.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--udt-overlay0);
+ font-size: 22px;
+}
+.music .meta { min-width: 0; }
+.music .title {
+ font-size: 15px;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.music .artist, .music .album { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.music .album { font-size: 12px; color: var(--udt-overlay0); }
+.music .seek { display: flex; align-items: center; gap: 10px; margin: 6px 0 12px; }
+.music .seek input { flex: 1; accent-color: var(--udt-accent); }
+.music .time { font-size: 12px; color: var(--udt-subtext0); white-space: nowrap; }
+.music .controls { display: flex; flex-wrap: wrap; gap: 8px; }
+
+.sbb .sbb-summary { margin-bottom: 8px; font-size: 12px; }
+.sbb .row.job .kind { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.grid-stack-item .ui-resizable-se { width: 22px; height: 22px; right: 2px; bottom: 2px; opacity: .45; }
+.grid-stack-item:hover .ui-resizable-se { opacity: 1; }
.num {
font-family: "Inconsolata Nerd Font Mono", ui-monospace, monospace;
diff --git a/static/modules/music.js b/static/modules/music.js
index de9df7f..e4388f7 100644
--- a/static/modules/music.js
+++ b/static/modules/music.js
@@ -1,9 +1,14 @@
-async function post(action) {
+function clock(s) {
+ s = Math.max(0, Math.floor(s || 0));
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+}
+
+async function post(action, body = {}) {
try {
await fetch(`/api/music/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: "{}",
+ body: JSON.stringify(body),
});
} catch {
// the next poll shows the real state
@@ -22,27 +27,51 @@ export default {
interval: 2,
mount(el) {
- const box = document.createElement("div");
- box.className = "music";
- box.innerHTML = `
- <div class="now"><b class="title">—</b><span class="artist muted"></span></div>
- <div class="row controls"></div>
- <div class="row vol"></div>`;
- box.querySelector(".controls").append(
- button("⏮", "prev"), button("⏯", "play"), button("⏭", "next"));
- box.querySelector(".vol").append(
+ el.innerHTML = `
+ <div class="top">
+ <div class="art empty">♪</div>
+ <div class="meta">
+ <div class="title">Nothing playing</div>
+ <div class="artist muted"></div>
+ <div class="album muted"></div>
+ </div>
+ </div>
+ <div class="seek">
+ <input type="range" min="0" max="1" step="1" value="0" aria-label="Seek">
+ <span class="time num">0:00</span>
+ </div>
+ <div class="controls"></div>`;
+ el.querySelector(".controls").append(
+ button("⏮", "prev"), button("⏯", "play"), button("⏭", "next"),
button("Vol−", "voldown"), button("Mute", "mute"), button("Vol+", "volup"));
- el.append(box);
+ const seek = el.querySelector(".seek input");
+ seek.addEventListener("pointerdown", () => { el._dragging = true; });
+ seek.addEventListener("pointerup", () => { el._dragging = false; });
+ seek.addEventListener("change", () => post("seek", { position: Number(seek.value) }));
},
update(st, el) {
- const box = el.querySelector(".music");
- box.querySelector(".title").textContent = st.title || "Nothing playing";
- box.querySelector(".artist").textContent = st.artist || "";
- box.querySelector(".controls button:nth-child(2)").textContent =
+ if (st.art) {
+ let img = el.querySelector("img.art");
+ if (!img) {
+ img = document.createElement("img");
+ img.className = "art";
+ img.alt = "";
+ el.querySelector(".art").replaceWith(img);
+ }
+ if (img.getAttribute("src") !== st.art) img.src = st.art;
+ }
+ el.querySelector(".title").textContent = st.title || "Nothing playing";
+ el.querySelector(".artist").textContent = st.artist || "";
+ el.querySelector(".album").textContent = st.album || "";
+ el.querySelector(".controls button:nth-child(2)").textContent =
st.status === "Playing" ? "⏸" : "▶";
- const mute = box.querySelector(".vol button:nth-child(2)");
+ const mute = el.querySelector(".controls button:nth-child(5)");
mute.classList.toggle("on", Boolean(st.volume?.muted));
mute.textContent = st.volume?.muted ? "Unmute" : "Mute";
+ const seek = el.querySelector(".seek input");
+ seek.max = Math.max(1, Math.floor(st.length || 0));
+ if (!el._dragging) seek.value = Math.min(Number(seek.max), Math.floor(st.position || 0));
+ el.querySelector(".time").textContent = `${clock(st.position)} / ${clock(st.length)}`;
},
};
diff --git a/static/modules/obs.js b/static/modules/obs.js
index 9c5c34c..7270f87 100644
--- a/static/modules/obs.js
+++ b/static/modules/obs.js
@@ -27,9 +27,9 @@ export default {
wrap.className = "obs";
wrap.innerHTML = `
<div class="row obs-controls"></div>
- <div class="obs-scenes"></div>
- <div class="obs-audio"></div>
- <div class="obs-sources"></div>`;
+ <div class="section-label">Scenes</div><div class="obs-scenes"></div>
+ <div class="section-label">Audio</div><div class="obs-audio"></div>
+ <div class="section-label">Sources</div><div class="obs-sources"></div>`;
el.append(wrap);
},
@@ -39,6 +39,9 @@ export default {
const audio = el.querySelector(".obs-audio");
const sources = el.querySelector(".obs-sources");
+ el.querySelectorAll(".section-label").forEach((label) => {
+ label.style.display = state.connected ? "" : "none";
+ });
if (!state.connected) {
controls.textContent = state.error ? `OBS offline: ${state.error}` : "OBS offline";
scenes.textContent = audio.textContent = sources.textContent = "";
diff --git a/static/modules/sbbuilder.js b/static/modules/sbbuilder.js
index 16745cb..56802a9 100644
--- a/static/modules/sbbuilder.js
+++ b/static/modules/sbbuilder.js
@@ -5,39 +5,54 @@ function chipClass(status) {
return "muted";
}
+function when(ts) {
+ if (!ts) return "";
+ return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+}
+
+function summary(jobs) {
+ const running = jobs.filter((j) => ["running", "busy", "starting"].includes(j.status)).length;
+ const failed = jobs.filter((j) => ["failed", "lost", "killed"].includes(j.status)).length;
+ const parts = [];
+ if (running) parts.push(`${running} running`);
+ if (failed) parts.push(`${failed} failed`);
+ if (!parts.length) parts.push(jobs.length ? `${jobs.length} recent` : "idle");
+ return parts.join(" · ");
+}
+
export default {
- title: "Builds",
+ title: "Build VM",
interval: 5,
mount(el) {
- const box = document.createElement("div");
- box.className = "sbb";
- el.append(box);
+ el.innerHTML = `<div class="sbb-summary muted"></div><div class="sbb-list"></div>`;
},
update(state, el) {
- const box = el.querySelector(".sbb");
- box.textContent = "";
+ const sum = el.querySelector(".sbb-summary");
+ const list = el.querySelector(".sbb-list");
if (state.offline) {
- box.textContent = "build VM unreachable";
+ sum.textContent = "";
+ list.textContent = "build VM unreachable";
return;
}
const jobs = state.jobs ?? [];
- if (!jobs.length) {
- box.textContent = "No jobs";
- return;
- }
+ sum.textContent = summary(jobs);
+ list.textContent = "";
for (const job of jobs.slice(0, 8)) {
const row = document.createElement("div");
- row.className = "row";
- const id = document.createElement("span");
- id.className = "num id";
- id.textContent = job.id;
+ row.className = "row job";
+ const kind = document.createElement("span");
+ kind.className = "kind";
+ kind.textContent = job.kind || "job";
+ const time = document.createElement("span");
+ time.className = "muted num";
+ time.textContent = when(job.created);
const status = document.createElement("span");
status.className = chipClass(job.status);
status.textContent = job.status;
- row.append(id, status);
- box.append(row);
+ row.append(kind, time, status);
+ list.append(row);
}
},
};
diff --git a/tests/test_music.py b/tests/test_music.py
index 675e72b..e073e59 100644
--- a/tests/test_music.py
+++ b/tests/test_music.py
@@ -25,6 +25,18 @@ class Music(unittest.TestCase):
def test_action_commands(self):
self.assertEqual(music.BASE_ACTIONS["next"], ["playerctl", "next"])
+ def test_seek_runs_playerctl(self):
+ calls = []
+ m = music.make({"run": lambda *a, **k: "", "popen": calls.append})
+ m.actions["seek"]({"position": 12.34})
+ self.assertEqual(calls[0], ["playerctl", "position", "12.3"])
+
+ def test_seek_rejects_bad_positions(self):
+ m = music.make({"run": lambda *a, **k: "", "popen": lambda cmd: None})
+ for bad in ({}, {"position": -1}, {"position": "x"}):
+ with self.assertRaises(music.ModuleError, msg=bad):
+ m.actions["seek"](bad)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_server.py b/tests/test_server.py
index bf571a6..3340398 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -91,6 +91,13 @@ class Server(unittest.TestCase):
h = {**HOST, **ORIGIN, "Content-Type": "application/json"}
self.assertEqual(self.req("POST", "/api/demo/boom", h, '{"x": null}')[0], 400)
+ def test_art_route(self):
+ from unittest import mock
+ with mock.patch.object(srv, "art", return_value=(b"PNGDATA", "image/png")):
+ status, body = self.req("GET", "/api/art", HOST)
+ self.assertEqual(status, 200)
+ self.assertEqual(body, b"PNGDATA")
+
if __name__ == "__main__":
unittest.main()