diff options
| -rw-r--r-- | deckit/modules/music.py | 19 | ||||
| -rw-r--r-- | deckit/modules/sbbuilder.py | 2 | ||||
| -rw-r--r-- | deckit/server.py | 25 | ||||
| -rw-r--r-- | static/app.js | 12 | ||||
| -rw-r--r-- | static/deck.css | 92 | ||||
| -rw-r--r-- | static/modules/music.js | 63 | ||||
| -rw-r--r-- | static/modules/obs.js | 9 | ||||
| -rw-r--r-- | static/modules/sbbuilder.js | 49 | ||||
| -rw-r--r-- | tests/test_music.py | 12 | ||||
| -rw-r--r-- | tests/test_server.py | 7 |
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() |
