-
-
- stopped
-
-
- Nothing playing
-
- 0:000:00
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ stopped
+
+
+ Nothing playing
+
+ 0:000:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Playlist
+
+
+
+
+
+ Double-click a row (or tap ▶) to play it now.
+
+
-
-
-
Playlist
-
-
- Double-click a row (or tap ▶) to play it now.
-
-
+
+
-
+
+
+
+
Library
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
Cart wall
+
+
+
+
+
+ Tap a pad to play it on air.
+
+
+
-
+
+
+
+
+
+ Categories
+ Folders are on the server, as it sees them.
+
+
+
+
+
+
+
+
Advert and jingle rules
+
+
+ Rules put adverts and jingles into the on-air playlist after a number of songs, every so many minutes, or at set times.
+
+
+
+
+
+
+ Station
+ Not loaded yet.
+
+
+
+
+
+ Broadcasting:
+
+
+
+
+
+ AutoDJ:
+
+
+
+
+
+
+ AzuraCast playlists
+
+
+
+
+
+
+
+
+
diff --git a/assets/portal/portal.css b/assets/portal/portal.css
index 39af756..d224ad5 100644
--- a/assets/portal/portal.css
+++ b/assets/portal/portal.css
@@ -2,19 +2,36 @@
--bg: #080e17; --panel: #0e1724; --line: #1f3048; --fg: #e6eef8; --muted: #8ea2bb;
--accent: #1e9bf0; --onair: #e5484d; --good: #3ccf7a; --warn: #f2b36b;
--mono: ui-monospace, "Cascadia Mono", Consolas, monospace;
+ --nav-h: 0px;
color-scheme: dark;
}
* { box-sizing: border-box; }
+/* the hidden attribute always wins (a section with its own display stays hidden until signed in) */
+[hidden] { display: none !important; }
+html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 system-ui, "Segoe UI", sans-serif; }
-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
+/* nothing shows until the script has decided between the sign-in box and the desk */
+body.boot main, body.boot nav { visibility: hidden; }
+.top { position: sticky; top: 0; z-index: 5; }
+header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 16px; padding-top: max(8px, env(safe-area-inset-top)); border-bottom: 1px solid var(--line); background: var(--panel); }
.brand { font-weight: 700; display: flex; align-items: center; gap: 8px; min-width: 0; }
+.brand img { flex: none; border-radius: 5px; }
.brand .muted { font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lamp { width: 10px; height: 10px; border-radius: 50%; background: var(--line); flex: none; }
.lamp.on { background: var(--onair); box-shadow: 0 0 8px var(--onair); }
-main { max-width: 1100px; margin: 0 auto; padding: 16px; }
-#desk { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
-#desk .playlist { grid-row: span 3; }
-@media (max-width: 760px) { #desk { grid-template-columns: minmax(0, 1fr); } #desk .playlist { grid-row: auto; } }
+
+nav { display: flex; gap: 4px; padding: 6px 12px; background: var(--panel); border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; }
+nav::-webkit-scrollbar { display: none; }
+nav button { display: flex; align-items: center; gap: 6px; background: transparent; border-color: transparent; white-space: nowrap; padding: 6px 12px; }
+nav button.on { background: var(--line); border-color: var(--accent); }
+nav .ic { font-size: 1rem; line-height: 1; }
+
+main { max-width: 1100px; margin: 0 auto; padding: 16px; padding-bottom: calc(16px + var(--nav-h)); }
+.grid2 { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
+.view[data-view="studio"] { grid-template-rows: auto 1fr; }
+.view[data-view="studio"] .playlist { grid-row: span 2; }
+.view:not(.grid2) > .card + .card { margin-top: 16px; }
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; min-width: 0; }
.narrow { max-width: 380px; margin: 40px auto; }
h1, h2 { margin: 0 0 8px; font-size: 1.15rem; }
@@ -25,38 +42,116 @@ h1 { font-size: 1.4rem; }
.center { text-align: center; }
.error { color: var(--onair); min-height: 1.2em; }
.row { display: flex; align-items: center; gap: 10px; }
+.row.wrap, .wrap { flex-wrap: wrap; }
.between { justify-content: space-between; }
+.end { justify-content: flex-end; }
label { display: block; margin: 8px 0; color: var(--muted); }
-input, select, button { font: inherit; color: var(--fg); }
-input:not([type=range]), select { width: 100%; padding: 8px 10px; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; }
-select { width: auto; max-width: 60%; }
-button { background: var(--line); border: 1px solid var(--line); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
+label.inline { display: flex; margin: 4px 0; color: var(--fg); }
+input, select, button, textarea { font: inherit; color: var(--fg); }
+input:not([type=range]):not([type=checkbox]):not([type=radio]), select, textarea { width: 100%; padding: 8px 10px; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; }
+textarea { min-height: 5em; resize: vertical; font-family: var(--mono); font-size: 0.85rem; }
+.row select, .row input:not([type=checkbox]):not([type=radio]) { width: auto; min-width: 0; }
+#libSearch { flex: 2 1 220px; width: auto; }
+#libFolder { flex: 1 1 160px; max-width: 100%; }
+select { max-width: 100%; }
+input[type=checkbox], input[type=radio] { width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
+button { background: var(--line); border: 1px solid var(--line); border-radius: 6px; padding: 8px 14px; cursor: pointer; touch-action: manipulation; }
button:hover { border-color: var(--accent); }
-button.primary, form button { background: var(--accent); border-color: var(--accent); color: #fff; }
+button.primary, #loginform button { background: var(--accent); border-color: var(--accent); color: #fff; }
+button.danger { background: #3a1518; border-color: #5a2328; }
button.ghost { background: transparent; }
+button.small, .items button { padding: 4px 10px; font-size: 0.85rem; }
button:disabled { opacity: 0.5; cursor: default; }
-.badge { text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.06em; padding: 2px 8px; border-radius: 4px; background: var(--line); }
+.badge { text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.06em; padding: 2px 8px; border-radius: 4px; background: var(--line); white-space: nowrap; }
.badge.playing { background: var(--onair); color: #fff; }
.badge.paused { background: var(--warn); color: #000; }
+.badge.advert { background: #3b2a55; }
+.badge.jingle { background: #1d3d4f; }
+.badge.off { background: transparent; border: 1px solid var(--line); color: var(--muted); }
+
+.banner { background: #3a2c12; border: 1px solid #6b5220; color: var(--fg); border-radius: 8px; padding: 10px 14px; margin-bottom: 16px; }
+.banner a { color: var(--warn); }
+
.title { font-size: 1.35rem; font-weight: 600; margin: 10px 0; overflow-wrap: anywhere; }
.bar { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; }
.bar div { height: 100%; width: 0; background: var(--accent); }
.meters { display: grid; gap: 3px; margin: 10px 0; }
.meters div { height: 4px; width: 0; background: var(--good); border-radius: 2px; }
.controls { display: flex; gap: 8px; flex-wrap: wrap; margin: 6px 0 10px; }
-.controls button { min-width: 52px; font-size: 1.1rem; }
+.onair .controls button { min-width: 52px; font-size: 1.1rem; flex: 1 0 52px; }
.volume input { flex: 1; }
-ol#rows { list-style: none; margin: 0; padding: 0; max-height: 70vh; overflow-y: auto; }
-#rows li { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid var(--line); cursor: default; user-select: none; }
-#rows li .n { color: var(--muted); font-family: var(--mono); font-size: 0.8rem; width: 2.6em; flex: none; }
-#rows li .t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-#rows li .d { color: var(--muted); font-family: var(--mono); font-size: 0.85rem; flex: none; }
-#rows li button { padding: 2px 8px; font-size: 0.8rem; flex: none; }
-#rows li.played .t { color: var(--muted); }
-#rows li.current { background: rgba(229, 72, 77, 0.15); }
-#rows li.current .t { font-weight: 600; }
-ul.plain { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto; }
-ul.plain li { padding: 3px 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
+
+ol.rows { list-style: none; margin: 0; padding: 0; max-height: 70vh; overflow-y: auto; }
+.rows li { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid var(--line); user-select: none; }
+.rows li .n { color: var(--muted); font-family: var(--mono); font-size: 0.8rem; width: 2.6em; flex: none; }
+.rows li .t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rows li .t small { display: block; color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
+.rows li .d { color: var(--muted); font-family: var(--mono); font-size: 0.85rem; flex: none; }
+.rows li button { padding: 3px 9px; font-size: 0.85rem; flex: none; }
+.rows li.played .t { color: var(--muted); }
+.rows li.current { background: rgba(229, 72, 77, 0.15); }
+.rows li.current .t { font-weight: 600; }
+.rows li.muted { justify-content: center; }
+
+ul.plain { list-style: none; margin: 0; padding: 0; max-height: 300px; overflow-y: auto; }
+ul.plain.tall { max-height: 65vh; }
+ul.plain.items { max-height: none; }
+ul.plain li { padding: 4px 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
ul.plain li.err { color: var(--onair); }
+.items li { display: flex; align-items: center; gap: 8px; padding: 8px 0; }
+.items li .grow { flex: 1; min-width: 0; }
+.items li .grow small { display: block; color: var(--muted); }
#encoders li { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.live { color: var(--good); }
+
+.carts { display: grid; gap: 8px; grid-template-columns: repeat(6, minmax(0, 1fr)); }
+.carts button { aspect-ratio: 4 / 3; padding: 6px; display: flex; flex-direction: column; justify-content: space-between; align-items: flex-start; text-align: left; overflow: hidden; background: #13233a; border-color: #23395a; }
+.carts button .k { color: var(--muted); font-size: 0.75rem; font-family: var(--mono); }
+.carts button .l { font-weight: 600; overflow-wrap: anywhere; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
+.carts button.empty { background: transparent; border-style: dashed; color: var(--muted); }
+.carts button.playing { background: var(--onair); border-color: var(--onair); color: #fff; }
+.carts button.playing .k { color: #fff; }
+.carts.editing button { border-color: var(--warn); }
+#cartEdit[aria-pressed="true"] { background: var(--warn); border-color: var(--warn); color: #000; }
+
+dialog { background: var(--panel); color: var(--fg); border: 1px solid var(--line); border-radius: 12px; padding: 16px; width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }
+dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
+dialog form { display: flex; flex-direction: column; max-height: calc(100dvh - 60px); }
+#dlgBody { overflow-y: auto; flex: 1; min-height: 0; padding-right: 2px; }
+dialog .row.end { margin-top: 12px; }
+.sub { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }
+.sub > .row { margin: 4px 0; }
+.sub .row input[type=number] { width: 5.5em; }
+.days { display: flex; flex-wrap: wrap; gap: 4px 12px; }
+.pick { max-height: 220px; overflow-y: auto; border: 1px solid var(--line); border-radius: 6px; margin-top: 6px; }
+.pick div { padding: 6px 8px; border-bottom: 1px solid var(--line); cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pick div:hover, .pick div:focus { background: var(--line); outline: none; }
+.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
+
+#toast { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(16px + var(--nav-h)); z-index: 20; background: var(--line); border: 1px solid var(--accent); border-radius: 8px; padding: 10px 16px; max-width: calc(100vw - 32px); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); }
+#toast.err { border-color: var(--onair); }
+
+@media (max-width: 860px) {
+ .carts { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+}
+/* phones: one column, the sections in a bar along the bottom within thumb reach */
+@media (max-width: 760px) {
+ :root { --nav-h: calc(58px + env(safe-area-inset-bottom)); }
+ body { font-size: 16px; }
+ header { padding: 8px 12px; }
+ main { padding: 12px; padding-bottom: calc(12px + var(--nav-h)); }
+ .grid2 { grid-template-columns: minmax(0, 1fr); gap: 12px; }
+ .view[data-view="studio"] { grid-template-rows: none; }
+ .view[data-view="studio"] .playlist { grid-row: auto; }
+ .card { padding: 12px; }
+ nav { position: fixed; z-index: 5; bottom: 0; left: 0; right: 0; border-top: 1px solid var(--line); border-bottom: 0; padding: 4px 4px env(safe-area-inset-bottom); gap: 0; height: var(--nav-h); }
+ nav button { flex: 1 0 54px; flex-direction: column; gap: 2px; padding: 6px 1px; font-size: 0.66rem; border-radius: 8px; }
+ nav .ic { font-size: 1.25rem; }
+ .rows li { padding: 8px 4px; }
+ .rows li button { padding: 6px 11px; }
+ ol.rows { max-height: none; }
+ .title { font-size: 1.2rem; }
+ .carts { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .narrow { margin: 16px auto; }
+}
+.card > button, .card form > button { margin-top: 8px; }
diff --git a/assets/portal/portal.js b/assets/portal/portal.js
index d0c8ac0..e06bf9d 100644
--- a/assets/portal/portal.js
+++ b/assets/portal/portal.js
@@ -3,14 +3,41 @@
const $ = (id) => document.getElementById(id);
const store = (() => { try { return window.sessionStorage; } catch (e) { return null; } })();
-let token = (store && store.getItem("rm_token")) || "";
-let station = (store && store.getItem("rm_station")) || "";
+const remember = (k, v) => { try { if (store) store.setItem(k, v); } catch (e) { /* private mode */ } };
+const recall = (k) => { try { return (store && store.getItem(k)) || ""; } catch (e) { return ""; } };
+let token = recall("rm_token");
+let station = recall("rm_station");
// what the server has already sent (it only sends lists again when they change)
let rev = 0, cfgRev = 0, logRev = 0, histRev = 0;
let tabs = null, cfg = null, live = null;
let viewTab = -1;
let timer = null;
let volumeBusy = false;
+let view = recall("rm_view") || "studio";
+let library = null, libVer = -1, libLoading = false;
+
+// ------------------------------------------------------------------ helpers
+
+/// An element with properties (`on*` are event handlers) and children (strings become text).
+function el(tag, props, ...children) {
+ const e = document.createElement(tag);
+ for (const [k, v] of Object.entries(props || {})) {
+ if (v === undefined || v === null || v === false) continue;
+ if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
+ else if (k === "class") e.className = v;
+ else if (k in e && k !== "list") e[k] = v;
+ else e.setAttribute(k, v === true ? "" : v);
+ }
+ for (const c of children.flat()) {
+ if (c === null || c === undefined || c === false) continue;
+ e.append(c instanceof Node ? c : document.createTextNode(String(c)));
+ }
+ return e;
+}
+
+class ApiError extends Error {
+ constructor(msg, status) { super(msg); this.status = status; }
+}
async function api(path, body) {
const opts = { method: body === undefined ? "GET" : "POST", headers: {} };
@@ -22,11 +49,21 @@ async function api(path, body) {
const r = await fetch("/api/v1/" + path, opts);
let data = null;
try { data = await r.json(); } catch (e) { /* empty answer */ }
- if (r.status === 401 && path !== "login") { signedOut(); throw new Error("signed out"); }
- if (!r.ok) throw new Error((data && data.error) || "HTTP " + r.status);
+ if (r.status === 401 && path !== "login") { signedOut(); throw new ApiError("signed out", 401); }
+ if (!r.ok) throw new ApiError((data && data.error) || "HTTP " + r.status, r.status);
return data;
}
+let toastTimer = null;
+function toast(msg, err) {
+ const t = $("toast");
+ t.textContent = msg;
+ t.className = err ? "err" : "";
+ t.hidden = false;
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => { t.hidden = true; }, err ? 6000 : 2500);
+}
+
function fmt(secs) {
secs = Math.max(0, Math.floor(secs || 0));
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
@@ -39,6 +76,13 @@ function label(track) {
return track.artist ? track.artist + " - " + track.title : track.title;
}
+function fileName(p) { return String(p || "").split(/[\\/]/).pop(); }
+function dirName(p) {
+ const s = String(p || "");
+ const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
+ return i > 0 ? s.slice(0, i) : "";
+}
+
/// A playlist row's text, like the app shows it.
function rowTitle(e) {
const k = e.kind;
@@ -51,11 +95,52 @@ function rowTitle(e) {
return e.track.title || name;
}
+function onAirTab() { return tabs ? tabs.playing : 0; }
+
+function tabOptions(sel, current) {
+ sel.replaceChildren();
+ if (!tabs) return;
+ tabs.tabs.forEach((t, i) => {
+ sel.append(el("option", { value: i }, (t.name || "Playlist " + (i + 1)) + (i === tabs.playing ? " (on air)" : "")));
+ });
+ sel.value = current >= 0 && current < tabs.tabs.length ? current : tabs.playing;
+}
+
+function send(...cmds) {
+ return api("cmd", cmds).then(poll).catch((e) => toast(e.message, true));
+}
+
+/// Change the station settings: `mutate` edits a copy of the latest settings. When someone else
+/// changed them first, it is done again on their version.
+async function saveCfg(mutate, done) {
+ for (let i = 0; i < 2; i++) {
+ const c = JSON.parse(JSON.stringify(cfg));
+ if (mutate(c) === false) return false;
+ try {
+ await api("config", { base: cfgRev, cfg: c });
+ cfgRev = 0; // fetch the saved copy
+ await poll();
+ toast(done || "Saved");
+ return true;
+ } catch (e) {
+ if (e.status === 409 && i === 0) { cfgRev = 0; await poll(); continue; }
+ toast("Not saved: " + e.message, true);
+ return false;
+ }
+ }
+ return false;
+}
+
+// ------------------------------------------------------------------ sign-in and views
+
function showDesk(on) {
+ document.body.classList.remove("boot");
$("login").hidden = on;
- $("desk").hidden = !on;
+ $("views").hidden = !on;
+ $("nav").hidden = !on;
$("signout").hidden = !on;
$("station").textContent = on ? station : "";
+ if (!on) $("update").hidden = true;
}
function signedOut() {
@@ -64,6 +149,7 @@ function signedOut() {
clearTimeout(timer);
rev = cfgRev = logRev = histRev = 0;
tabs = cfg = live = null;
+ library = null; libVer = -1;
showDesk(false);
}
@@ -75,7 +161,8 @@ $("loginform").addEventListener("submit", async (ev) => {
const r = await api("login", { user: $("user").value.trim(), password: $("password").value });
token = r.token;
station = r.name || "";
- if (store) { store.setItem("rm_token", token); store.setItem("rm_station", station); }
+ remember("rm_token", token);
+ remember("rm_station", station);
$("password").value = "";
start();
} catch (e) {
@@ -90,11 +177,31 @@ $("signout").addEventListener("click", async () => {
signedOut();
});
-function send(...cmds) {
- return api("cmd", cmds).then(poll).catch((e) => { $("neterr").textContent = e.message; });
+function showView(v) {
+ if (!document.querySelector(`.view[data-view="${v}"]`)) v = "studio";
+ view = v;
+ remember("rm_view", v);
+ document.querySelectorAll(".view").forEach((x) => { x.hidden = x.dataset.view !== v; });
+ document.querySelectorAll("#nav button").forEach((b) => b.classList.toggle("on", b.dataset.view === v));
+ renderView();
+ window.scrollTo(0, 0);
}
-function onAirTab() { return tabs ? tabs.playing : 0; }
+document.querySelectorAll("#nav button").forEach((b) => b.addEventListener("click", () => showView(b.dataset.view)));
+
+/// Draw what the current view shows from the latest copy of the station.
+function renderView() {
+ if (!tabs || !cfg) return;
+ switch (view) {
+ case "library": tabOptions($("libTab"), parseInt($("libTab").value, 10)); loadLibrary(); break;
+ case "carts": renderCarts(); break;
+ case "generator": renderGenerator(); break;
+ case "ads": renderRules(); break;
+ case "azuracast": renderAzForm(); azLoad(); break;
+ }
+}
+
+// ------------------------------------------------------------------ studio
document.querySelectorAll("[data-cmd]").forEach((b) => b.addEventListener("click", () => {
switch (b.dataset.cmd) {
@@ -113,6 +220,11 @@ $("volume").addEventListener("change", () => {
});
$("tab").addEventListener("change", () => { viewTab = parseInt($("tab").value, 10); renderRows(); });
+$("toLibrary").addEventListener("click", () => {
+ showView("library");
+ $("libTab").value = viewTab < 0 ? onAirTab() : viewTab;
+ $("libSearch").focus();
+});
function playRow(row) {
const tab = viewTab < 0 ? onAirTab() : viewTab;
@@ -123,17 +235,15 @@ function playRow(row) {
send(...cmds);
}
+function removeRow(row) {
+ const tab = viewTab < 0 ? onAirTab() : viewTab;
+ send({ cmd: "remove_rows", tab, rows: [row] });
+}
+
function renderTabs() {
- const sel = $("tab");
- sel.replaceChildren();
- tabs.tabs.forEach((t, i) => {
- const o = document.createElement("option");
- o.value = i;
- o.textContent = (t.name || "Playlist " + (i + 1)) + (i === tabs.playing ? " (on air)" : "");
- sel.appendChild(o);
- });
if (viewTab < 0 || viewTab >= tabs.tabs.length) viewTab = tabs.playing;
- sel.value = viewTab;
+ tabOptions($("tab"), viewTab);
+ tabOptions($("libTab"), parseInt($("libTab").value, 10));
}
function renderRows() {
@@ -143,50 +253,34 @@ function renderRows() {
const t = tabs.tabs[viewTab];
if (!t) return;
if (!t.entries.length) {
- const li = document.createElement("li"); li.className = "muted"; li.textContent = "This playlist is empty.";
- ol.appendChild(li);
+ ol.append(el("li", { class: "muted" }, "This playlist is empty. ", el("button", { onclick: () => $("toLibrary").click() }, "Add songs")));
return;
}
const frag = document.createDocumentFragment();
t.entries.forEach((e, i) => {
- const li = document.createElement("li");
- if (e.played) li.classList.add("played");
- if (t.current === i) li.classList.add("current");
- const n = document.createElement("span"); n.className = "n"; n.textContent = String(i + 1).padStart(3, "0");
- const ti = document.createElement("span"); ti.className = "t"; ti.textContent = rowTitle(e); ti.title = ti.textContent;
- const d = document.createElement("span"); d.className = "d"; d.textContent = e.track.duration ? fmt(e.track.duration) : "";
- const b = document.createElement("button"); b.textContent = "▶"; b.title = "Play now";
- b.addEventListener("click", () => playRow(i));
- li.addEventListener("dblclick", () => playRow(i));
- li.append(n, ti, d, b);
- frag.appendChild(li);
+ const text = rowTitle(e);
+ const li = el("li", { class: (e.played ? "played " : "") + (t.current === i ? "current" : ""), ondblclick: () => playRow(i) },
+ el("span", { class: "n" }, String(i + 1).padStart(3, "0")),
+ el("span", { class: "t", title: text }, text),
+ el("span", { class: "d" }, e.track.duration ? fmt(e.track.duration) : ""),
+ el("button", { title: "Play now", "aria-label": "Play now", onclick: () => playRow(i) }, "▶"),
+ el("button", { class: "ghost", title: "Remove", "aria-label": "Remove from the playlist", disabled: t.current === i, onclick: () => removeRow(i) }, "✕"));
+ frag.append(li);
});
- ol.appendChild(frag);
+ ol.append(frag);
}
function renderEncoders() {
const ul = $("encoders");
ul.replaceChildren();
const list = (cfg && cfg.encoders) || [];
- if (!list.length) {
- const li = document.createElement("li"); li.className = "muted"; li.textContent = "No streams set up.";
- ul.appendChild(li);
- return;
- }
+ if (!list.length) { ul.append(el("li", { class: "muted" }, "No streams set up.")); return; }
list.forEach((enc, i) => {
const st = live && live.encoders ? live.encoders[i] : null;
- const li = document.createElement("li");
- const name = document.createElement("span");
- name.textContent = enc.name || "Stream " + (i + 1);
- const state = document.createElement("span");
- state.className = "small " + (st && st.state === "live" ? "live" : "muted");
- state.textContent = st ? st.state + (st.error ? ": " + st.error : "") : "off";
- const b = document.createElement("button");
- b.textContent = st ? "Stop" : "Start";
- b.addEventListener("click", () => send({ cmd: st ? "encoder_stop" : "encoder_start", i }));
- const left = document.createElement("span"); left.append(name, document.createTextNode(" "), state);
- li.append(left, b);
- ul.appendChild(li);
+ ul.append(el("li", {},
+ el("span", {}, enc.name || "Stream " + (i + 1), " ",
+ el("span", { class: "small " + (st && st.state === "live" ? "live" : "muted") }, st ? st.state + (st.error ? ": " + st.error : "") : "off")),
+ el("button", { onclick: () => send({ cmd: st ? "encoder_stop" : "encoder_start", i }) }, st ? "Stop" : "Start")));
});
}
@@ -194,16 +288,11 @@ function renderList(id, items, text, err) {
const ul = $(id);
ul.replaceChildren();
const frag = document.createDocumentFragment();
- items.slice().reverse().forEach((x) => {
- const li = document.createElement("li");
- li.textContent = text(x);
- if (err && err(x)) li.className = "err";
- frag.appendChild(li);
- });
- ul.appendChild(frag);
+ items.slice().reverse().forEach((x) => frag.append(el("li", { class: err && err(x) ? "err" : "" }, text(x))));
+ ul.append(frag);
}
-let encKey = "";
+let encKey = "", cartKey = "";
function renderLive() {
const l = live;
@@ -221,22 +310,589 @@ function renderLive() {
$("notify").textContent = l.notify || "";
const key = JSON.stringify(l.encoders);
if (key !== encKey) { encKey = key; renderEncoders(); }
+ const ck = JSON.stringify(l.carts);
+ if (ck !== cartKey) { cartKey = ck; if (view === "carts") renderCarts(); }
+ if (view === "library" && library && l.scanning) $("libInfo").textContent = "Scanning the library folders… " + (l.scan_progress || "");
+}
+
+// ------------------------------------------------------------------ library
+
+let libTimer = null;
+$("libSearch").addEventListener("input", () => { clearTimeout(libTimer); libTimer = setTimeout(renderLibrary, 150); });
+$("libFolder").addEventListener("change", renderLibrary);
+$("rescan").addEventListener("click", () => send({ cmd: "rescan" }).then(() => toast("Rescanning the library folders")));
+
+async function loadLibrary(force) {
+ if (libLoading || (library && !force && libVer === (live ? live.lib_ver : libVer))) { renderLibrary(); return; }
+ libLoading = true;
+ $("libInfo").textContent = "Loading the library…";
+ try {
+ library = await api("library");
+ libVer = live ? live.lib_ver : 0;
+ const dirs = [...new Set(library.map((t) => dirName(t.path)))].sort((a, b) => a.localeCompare(b));
+ const sel = $("libFolder"), cur = sel.value;
+ sel.replaceChildren(el("option", { value: "" }, "Every folder"), ...dirs.map((d) => el("option", { value: d }, d)));
+ sel.value = dirs.includes(cur) ? cur : "";
+ renderLibrary();
+ } catch (e) {
+ $("libInfo").textContent = "Couldn't load the library: " + e.message;
+ } finally {
+ libLoading = false;
+ }
+}
+
+/// Library tracks matching the search (all words, anywhere) and folder.
+function libMatches(q, folder, limit) {
+ const words = q.toLowerCase().split(/\s+/).filter(Boolean);
+ const out = [];
+ let total = 0;
+ for (const t of library || []) {
+ if (folder && dirName(t.path) !== folder) continue;
+ if (words.length) {
+ const hay = (t.artist + " " + t.title + " " + t.album + " " + t.genre + " " + fileName(t.path)).toLowerCase();
+ if (!words.every((w) => hay.includes(w))) continue;
+ }
+ total++;
+ if (out.length < limit) out.push(t);
+ }
+ return { out, total };
+}
+
+function renderLibrary() {
+ if (!library) return;
+ const { out, total } = libMatches($("libSearch").value, $("libFolder").value, 300);
+ $("libInfo").textContent = library.length
+ ? `${total} of ${library.length} tracks` + (total > out.length ? ` (the first ${out.length} shown: search to narrow it down)` : "")
+ : "The library is empty. Add library folders in the app (Settings → Library) and rescan.";
+ const ol = $("libRows");
+ ol.replaceChildren();
+ const frag = document.createDocumentFragment();
+ for (const t of out) {
+ const text = label(t) || fileName(t.path);
+ frag.append(el("li", {},
+ el("span", { class: "t", title: t.path }, text, el("small", {}, [t.album, t.genre, fileName(t.path)].filter(Boolean).join(" · "))),
+ el("span", { class: "d" }, t.duration ? fmt(t.duration) : ""),
+ el("button", { title: "Play after the current song", onclick: () => addTracks([t.path], true) }, "Next"),
+ el("button", { title: "Add to the end of the playlist", "aria-label": "Add", onclick: () => addTracks([t.path], false) }, "+")));
+ }
+ ol.append(frag);
+}
+
+/// Put files into the chosen playlist: at the end, or (`next`) after the row on air.
+function addTracks(paths, next) {
+ let tab = parseInt($("libTab").value, 10);
+ if (isNaN(tab)) tab = onAirTab();
+ let at = null;
+ if (next) {
+ const t = tabs && tabs.tabs[tab];
+ at = t && t.current !== null && t.current !== undefined ? t.current + 1 : 0;
+ }
+ send({ cmd: "insert", tab, paths, at }).then(() => toast(next ? "Plays next" : "Added"));
+}
+
+// ------------------------------------------------------------------ dialog
+
+let dlgSave = null;
+/// Show the editing dialog; `save` returns false to keep it open.
+function dialog(title, body, save, okText) {
+ $("dlgTitle").textContent = title;
+ $("dlgBody").replaceChildren(...[].concat(body));
+ $("dlgOk").textContent = okText || "Save";
+ dlgSave = save;
+ $("dlg").showModal();
+}
+$("dlgForm").addEventListener("submit", async (ev) => {
+ ev.preventDefault();
+ const ok = ev.submitter ? ev.submitter.value === "ok" : true;
+ if (ok && dlgSave) {
+ if (!$("dlgForm").reportValidity()) return;
+ $("dlgOk").disabled = true;
+ try { if ((await dlgSave()) === false) return; } finally { $("dlgOk").disabled = false; }
+ }
+ $("dlg").close();
+});
+// the view isn't redrawn while the dialog is open: catch up with what changed meanwhile
+$("dlg").addEventListener("close", () => renderView());
+
+function field(text, input) { return el("label", {}, text, " ", input); }
+function check(text, on) {
+ const i = el("input", { type: "checkbox", checked: !!on });
+ return [el("label", { class: "row inline" }, i, " ", text), i];
+}
+function num(v, min, max, step) { return el("input", { type: "number", value: v, min, max, step: step || 1 }); }
+function select(options, cur) {
+ const s = el("select", {}, ...options.map(([v, t]) => el("option", { value: v }, t)));
+ s.value = cur;
+ return s;
+}
+
+/// A search box over the library that fills `target` with the picked file's path.
+function filePicker(target, onPick) {
+ const q = el("input", { type: "search", placeholder: "Search the library for a file" });
+ const list = el("div", { class: "pick", hidden: true });
+ const draw = () => {
+ list.replaceChildren();
+ if (!q.value.trim()) { list.hidden = true; return; }
+ const { out } = libMatches(q.value, "", 50);
+ list.hidden = false;
+ if (!out.length) list.append(el("div", { class: "muted" }, "No matches"));
+ out.forEach((t) => list.append(el("div", { tabindex: 0, title: t.path,
+ onclick: () => { target.value = t.path; if (onPick) onPick(t); q.value = ""; draw(); } }, label(t) || fileName(t.path))));
+ };
+ q.addEventListener("input", draw);
+ if (!library) loadLibrary();
+ return el("div", {}, q, list);
+}
+
+/// The library's folders, offered while typing a folder.
+function folderList() {
+ const id = "folders-list";
+ let dl = document.getElementById(id);
+ if (!dl) { dl = el("datalist", { id }); document.body.append(dl); }
+ const dirs = new Set((library || []).map((t) => dirName(t.path)));
+ ((cfg && cfg.library_folders) || []).forEach((d) => dirs.add(d));
+ dl.replaceChildren(...[...dirs].sort().map((d) => el("option", { value: d })));
+ if (!library) loadLibrary().then(() => folderList());
+ return id;
}
+// ------------------------------------------------------------------ cart wall
+
+let cartEditing = false;
+$("cartEdit").addEventListener("click", () => {
+ cartEditing = !cartEditing;
+ $("cartEdit").setAttribute("aria-pressed", cartEditing);
+ $("cartEdit").textContent = cartEditing ? "Done" : "Edit pads";
+ $("cartHint").textContent = cartEditing ? "Tap a pad to choose its sound and name." : "Tap a pad to play it on air.";
+ renderCarts();
+});
+$("cartStop").addEventListener("click", () => send({ cmd: "stop_carts" }));
+
+function samePath(a, b) { return String(a).replace(/\\/g, "/").toLowerCase() === String(b).replace(/\\/g, "/").toLowerCase(); }
+
+function renderCarts() {
+ const box = $("carts");
+ box.classList.toggle("editing", cartEditing);
+ box.replaceChildren();
+ const playing = (live && live.carts) || [];
+ ((cfg && cfg.carts) || []).forEach((pad, i) => {
+ const empty = !pad.path;
+ const on = !empty && playing.some((p) => samePath(p, pad.path));
+ box.append(el("button", {
+ class: (empty ? "empty " : "") + (on ? "playing" : ""),
+ title: pad.path || "Empty pad",
+ onclick: () => (cartEditing || empty ? editCart(i) : send({ cmd: "cart", idx: i })),
+ }, el("span", { class: "k" }, String(i + 1)), el("span", { class: "l" }, pad.label || fileName(pad.path) || (cartEditing ? "Set…" : "—"))));
+ });
+}
+
+function editCart(i) {
+ const pad = cfg.carts[i] || { label: "", path: "" };
+ const name = el("input", { value: pad.label, placeholder: "Shown on the pad" });
+ const path = el("input", { value: pad.path, placeholder: "A file on the server" });
+ dialog("Pad " + (i + 1), [
+ field("Name", name),
+ field("Sound file", path),
+ filePicker(path, (t) => { if (!name.value) name.value = t.title || fileName(t.path); }),
+ el("p", { class: "muted small" }, "Leave the file empty to clear the pad."),
+ ], () => saveCfg((c) => {
+ while (c.carts.length <= i) c.carts.push({ label: "", path: "" });
+ c.carts[i].label = name.value.trim();
+ c.carts[i].path = path.value.trim();
+ }));
+}
+
+// ------------------------------------------------------------------ playlist generator
+
+/// Sources a template step can use: categories, and the files of advert / jingle rules.
+function sources() {
+ const s = (cfg.categories || []).map((c) => [c.name, c.name]);
+ (cfg.ad_rules || []).forEach((r) => s.push(["rule:" + r.id, (r.adverts ? "Adverts: " : "Jingles: ") + r.name]));
+ return s;
+}
+
+function renderGenerator() {
+ const names = (cfg.templates || []).map((t) => [t.name, t.name]);
+ const gt = $("genTemplate"), cur = gt.value;
+ gt.replaceChildren(...names.map(([v, t]) => el("option", { value: v }, t)));
+ if (names.some(([v]) => v === cur)) gt.value = cur;
+ tabOptions($("genTab"), parseInt($("genTab").value, 10));
+ const at = $("autoTemplate");
+ at.replaceChildren(...names.map(([v, t]) => el("option", { value: v }, t)));
+ at.value = cfg.auto_template;
+ $("autoOn").checked = !!cfg.auto_enabled;
+ $("autoMins").value = cfg.auto_min_mins;
+
+ const tl = $("templates");
+ tl.replaceChildren();
+ (cfg.templates || []).forEach((t, i) => {
+ const steps = (t.pattern || []).map((p) => p.count + "× " + srcName(p.category)).join(", ");
+ tl.append(el("li", {},
+ el("span", { class: "grow" }, t.name, el("small", {}, `${t.minutes} min · ${steps || "no steps"}`)),
+ el("button", { onclick: () => editTemplate(i) }, "Edit"),
+ el("button", { class: "ghost", "aria-label": "Delete", title: "Delete",
+ onclick: () => { if (confirm(`Delete the template "${t.name}"?`)) saveCfg((c) => { c.templates.splice(i, 1); }, "Deleted"); } }, "✕")));
+ });
+ if (!(cfg.templates || []).length) tl.append(el("li", { class: "muted" }, "No templates yet."));
+
+ const cl = $("categories");
+ cl.replaceChildren();
+ (cfg.categories || []).forEach((c, i) => {
+ const what = [c.folder || "any folder", c.genre && "genre " + c.genre].filter(Boolean).join(" · ");
+ cl.append(el("li", {},
+ el("span", { class: "grow" }, c.name, el("small", {}, what)),
+ el("button", { onclick: () => editCategory(i) }, "Edit"),
+ el("button", { class: "ghost", "aria-label": "Delete", title: "Delete",
+ onclick: () => { if (confirm(`Delete the category "${c.name}"?`)) saveCfg((x) => { x.categories.splice(i, 1); }, "Deleted"); } }, "✕")));
+ });
+ if (!(cfg.categories || []).length) cl.append(el("li", { class: "muted" }, "No categories yet."));
+}
+
+function srcName(v) {
+ const s = sources().find(([k]) => k === v);
+ return s ? s[1] : v;
+}
+
+$("genRun").addEventListener("click", () => {
+ const name = $("genTemplate").value;
+ if (!name) return toast("Make a template first", true);
+ const append = document.querySelector('input[name="genMode"]:checked').value === "append";
+ const tab = parseInt($("genTab").value, 10);
+ if (!append && !confirm("Replace everything in that playlist?")) return;
+ send({ cmd: "generate", tab: isNaN(tab) ? onAirTab() : tab, name, append }).then(() => toast("Generated from " + name));
+});
+
+$("autoSave").addEventListener("click", () => saveCfg((c) => {
+ c.auto_enabled = $("autoOn").checked;
+ c.auto_template = $("autoTemplate").value;
+ c.auto_min_mins = Math.max(1, parseInt($("autoMins").value, 10) || 30);
+}));
+
+$("tplAdd").addEventListener("click", () => editTemplate(-1));
+$("catAdd").addEventListener("click", () => editCategory(-1));
+
+/// Rows of (source, numbers…) with add and remove: template steps and markers.
+function stepRows(items, make, fields, addText) {
+ const box = el("div");
+ const draw = () => {
+ box.replaceChildren();
+ items.forEach((it, k) => {
+ const src = select(sources(), it.category);
+ src.addEventListener("change", () => { it.category = src.value; });
+ const nums = fields.map(([key, text, min, max]) => {
+ const n = num(it[key], min, max);
+ n.addEventListener("change", () => { it[key] = Math.max(min, parseInt(n.value, 10) || min); });
+ return el("label", { class: "row inline small" }, text, " ", n);
+ });
+ box.append(el("div", { class: "row wrap" }, src, ...nums,
+ el("button", { type: "button", class: "ghost", "aria-label": "Remove", onclick: () => { items.splice(k, 1); draw(); } }, "✕")));
+ });
+ box.append(el("button", { type: "button", class: "small", onclick: () => { items.push(make()); draw(); } }, addText));
+ };
+ draw();
+ return box;
+}
+
+function editTemplate(i) {
+ const t = i >= 0 ? JSON.parse(JSON.stringify(cfg.templates[i]))
+ : { name: "New template", minutes: 120, pattern: [{ category: (cfg.categories[0] || {}).name || "Music", count: 1 }], markers: [], artist_gap: 5, hour_markers: false };
+ t.pattern = t.pattern || [];
+ t.markers = t.markers || [];
+ const first = (sources()[0] || ["Music"])[0];
+ const name = el("input", { value: t.name, required: true });
+ const minutes = num(t.minutes, 1, 1440 * 7);
+ const gap = num(t.artist_gap, 0, 100);
+ const [hourRow, hour] = check("An hour marker at the top of every hour", t.hour_markers);
+ dialog(i >= 0 ? "Template" : "New template", [
+ field("Name", name),
+ el("div", { class: "pair" }, field("Length (minutes)", minutes), field("Songs before an artist repeats", gap)),
+ el("div", { class: "sub" }, el("strong", {}, "Steps"), el("p", { class: "muted small" }, "Played in order, then again from the top, until the length is reached."),
+ stepRows(t.pattern, () => ({ category: first, count: 1 }), [["count", "×", 1, 50]], "+ Step")),
+ el("div", { class: "sub" }, el("strong", {}, "Markers"), el("p", { class: "muted small" }, "Put in every so many minutes, e.g. a station ID every 30 minutes."),
+ stepRows(t.markers, () => ({ category: first, every_mins: 30, count: 1 }), [["every_mins", "every (min)", 1, 1440], ["count", "×", 1, 20]], "+ Marker")),
+ hourRow,
+ ], () => {
+ const n = name.value.trim();
+ if ((cfg.templates || []).some((x, k) => k !== i && x.name === n)) { toast("There is already a template called " + n, true); return false; }
+ t.name = n;
+ t.minutes = Math.max(1, parseInt(minutes.value, 10) || 60);
+ t.artist_gap = Math.max(0, parseInt(gap.value, 10) || 0);
+ t.hour_markers = hour.checked;
+ const old = i >= 0 ? cfg.templates[i].name : null;
+ return saveCfg((c) => {
+ const k = old === null ? -1 : c.templates.findIndex((x) => x.name === old);
+ if (k >= 0) c.templates[k] = t; else c.templates.push(t);
+ if (old !== null && old !== n && c.auto_template === old) c.auto_template = n;
+ });
+ });
+}
+
+function editCategory(i) {
+ const c = i >= 0 ? JSON.parse(JSON.stringify(cfg.categories[i])) : { name: "New category", folder: "", genre: "", min_secs: 0, max_secs: 0 };
+ const name = el("input", { value: c.name, required: true });
+ const folder = el("input", { value: c.folder, list: folderList(), placeholder: "Any folder" });
+ const genre = el("input", { value: c.genre, placeholder: "Any genre" });
+ const min = num(c.min_secs, 0, 36000);
+ const max = num(c.max_secs, 0, 36000);
+ dialog(i >= 0 ? "Category" : "New category", [
+ field("Name", name),
+ field("Folder on the server (and the folders in it)", folder),
+ field("Genre contains", genre),
+ el("div", { class: "pair" }, field("Shortest (seconds)", min), field("Longest (seconds, 0: any)", max)),
+ ], () => {
+ const n = name.value.trim();
+ if ((cfg.categories || []).some((x, k) => k !== i && x.name === n)) { toast("There is already a category called " + n, true); return false; }
+ const old = i >= 0 ? cfg.categories[i].name : null;
+ c.name = n;
+ c.folder = folder.value.trim();
+ c.genre = genre.value.trim();
+ c.min_secs = parseFloat(min.value) || 0;
+ c.max_secs = parseFloat(max.value) || 0;
+ return saveCfg((x) => {
+ const k = old === null ? -1 : x.categories.findIndex((y) => y.name === old);
+ if (k >= 0) x.categories[k] = c; else x.categories.push(c);
+ // the templates follow a renamed category
+ if (old !== null && old !== n) {
+ (x.templates || []).forEach((t) => [...(t.pattern || []), ...(t.markers || [])].forEach((p) => { if (p.category === old) p.category = n; }));
+ }
+ });
+ });
+}
+
+// ------------------------------------------------------------------ advert and jingle rules
+
+const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+const pad2 = (n) => String(n).padStart(2, "0");
+
+/// One line saying what a rule does (as the app says it).
+function ruleSummary(r) {
+ const what = r.adverts ? "advert" : "jingle";
+ const n = Math.max(1, r.count);
+ const pick = r.pick === "All" ? `all the ${what}s` : r.pick === "InOrder" ? `${n} ${what}${n === 1 ? "" : "s"} in turn` : `${n} random ${what}${n === 1 ? "" : "s"}`;
+ let when;
+ if (r.trigger === "Songs") when = `after every ${Math.max(1, r.every_songs)} song${r.every_songs === 1 ? "" : "s"}`;
+ else if (r.trigger === "Minutes") {
+ const m = Math.max(1, r.every_mins);
+ when = "every " + (m % 60 === 0 ? `${m / 60} hour${m === 60 ? "" : "s"}` : `${m} minutes`) + (r.on_clock ? " (on the clock)" : "");
+ } else when = "at " + ((r.times || []).map(([h, m]) => pad2(h) + ":" + pad2(m)).join(", ") || "—");
+ const limits = [];
+ if (!(r.days || []).every(Boolean)) limits.push(DAYS.filter((_, k) => r.days[k]).join(" "));
+ if (r.from_hour !== r.to_hour) limits.push(`${pad2(r.from_hour)}:00–${pad2(r.to_hour)}:00`);
+ if (r.from_date || r.to_date) limits.push(`${r.from_date || "…"} → ${r.to_date || "…"}`);
+ return `${pick} ${when}` + (limits.length ? " · " + limits.join(" · ") : "");
+}
+
+function renderRules() {
+ const ul = $("rules");
+ ul.replaceChildren();
+ (cfg.ad_rules || []).forEach((r, i) => {
+ const on = el("input", { type: "checkbox", checked: r.enabled, title: "On", "aria-label": "Rule on" });
+ on.addEventListener("change", () => saveCfg((c) => { const x = c.ad_rules.find((y) => y.id === r.id); if (x) x.enabled = on.checked; }, on.checked ? "Rule on" : "Rule off"));
+ ul.append(el("li", {},
+ on,
+ el("span", { class: "grow" }, el("span", { class: "badge " + (r.adverts ? "advert" : "jingle") }, r.adverts ? "Adverts" : "Jingles"), " ", r.name,
+ el("small", {}, ruleSummary(r))),
+ el("button", { title: "Put them in the on-air playlist now", onclick: () => send({ cmd: "fire_rule", tab: onAirTab(), id: r.id, after: null }).then(() => toast("Queued " + r.name)) }, "Play now"),
+ el("button", { onclick: () => editRule(i) }, "Edit"),
+ el("button", { class: "ghost", "aria-label": "Delete", title: "Delete",
+ onclick: () => { if (confirm(`Delete the rule "${r.name}"?`)) saveCfg((c) => { c.ad_rules = c.ad_rules.filter((y) => y.id !== r.id); }, "Deleted"); } }, "✕")));
+ });
+ if (!(cfg.ad_rules || []).length) ul.append(el("li", { class: "muted" }, "No rules yet."));
+}
+
+$("ruleAdd").addEventListener("click", () => editRule(-1));
+
+function editRule(i) {
+ const r = i >= 0 ? JSON.parse(JSON.stringify(cfg.ad_rules[i])) : {
+ id: 0, name: "Jingles", enabled: true, adverts: false, trigger: "Songs", every_songs: 4, every_mins: 30, on_clock: true,
+ times: [[12, 0]], use_ad_list: false, folder: "", files: [], count: 1, pick: "Random", next: 0,
+ from_date: "", to_date: "", days: [true, true, true, true, true, true, true], from_hour: 0, to_hour: 0,
+ };
+ const name = el("input", { value: r.name, required: true });
+ const kind = select([["false", "Jingles"], ["true", "Adverts"]], String(!!r.adverts));
+ const trigger = select([["Songs", "After a number of songs"], ["Minutes", "Every so many minutes"], ["Times", "At set times"]], r.trigger);
+ const songs = num(r.every_songs, 1, 1000);
+ const mins = num(r.every_mins, 1, 1440);
+ const [clockRow, clock] = check("Counted from the top of the hour (:00, :30…)", r.on_clock);
+ const times = el("input", { value: (r.times || []).map(([h, m]) => pad2(h) + ":" + pad2(m)).join(", "), placeholder: "08:00, 12:30, 17:45" });
+ const songsRow = field("Songs between", songs);
+ const minsBox = el("div", {}, field("Minutes between", mins), clockRow);
+ const timesRow = field("Times of day", times);
+ const showTrigger = () => {
+ songsRow.hidden = trigger.value !== "Songs";
+ minsBox.hidden = trigger.value !== "Minutes";
+ timesRow.hidden = trigger.value !== "Times";
+ };
+ trigger.addEventListener("change", showTrigger);
+ showTrigger();
+ const [adListRow, adList] = check("Take them from the Adverts list (each with its own dates and days)", r.use_ad_list);
+ const folder = el("input", { value: r.folder, list: folderList(), placeholder: "A folder on the server" });
+ const files = el("textarea", { value: (r.files || []).join("\n"), placeholder: "Or chosen files, one per line (used instead of the folder)" });
+ const picker = filePicker({ set value(p) { files.value = (files.value.trim() ? files.value.trim() + "\n" : "") + p; } });
+ const count = num(r.count, 1, 50);
+ const pick = select([["Random", "Random ones"], ["InOrder", "One after another"], ["All", "All of them"]], r.pick);
+ const showKind = () => { adListRow.hidden = kind.value !== "true"; };
+ kind.addEventListener("change", showKind);
+ showKind();
+ const days = r.days.map((d) => el("input", { type: "checkbox", checked: d }));
+ const hours = [...Array(24).keys()].map((h) => [h, pad2(h) + ":00"]);
+ const fromH = select(hours, r.from_hour), toH = select(hours, r.to_hour);
+ const fromD = el("input", { type: "date", value: r.from_date }), toD = el("input", { type: "date", value: r.to_date });
+ dialog(i >= 0 ? "Rule" : "New rule", [
+ el("div", { class: "pair" }, field("Name", name), field("Plays", kind)),
+ el("div", { class: "sub" }, field("When", trigger), songsRow, minsBox, timesRow),
+ el("div", { class: "sub" }, el("strong", {}, "What plays"), adListRow, field("Folder", folder), files, picker,
+ el("div", { class: "pair" }, field("How many each time", count), field("Which", pick))),
+ el("div", { class: "sub" }, el("strong", {}, "In force"),
+ el("div", { class: "days" }, ...DAYS.map((d, k) => el("label", { class: "row inline" }, days[k], " ", d))),
+ el("div", { class: "pair" }, field("From", fromH), field("Until (the same: all day)", toH)),
+ el("div", { class: "pair" }, field("From date", fromD), field("Until date", toD))),
+ ], () => {
+ const parsed = [];
+ if (trigger.value === "Times") {
+ for (const p of times.value.split(/[,;\s]+/).filter(Boolean)) {
+ const m = /^(\d{1,2})[:.]?(\d{2})$/.exec(p);
+ if (!m || +m[1] > 23 || +m[2] > 59) { toast("Times look like 08:00 or 17:45 (not " + p + ")", true); return false; }
+ parsed.push([+m[1], +m[2]]);
+ }
+ if (!parsed.length) { toast("Give at least one time", true); return false; }
+ }
+ Object.assign(r, {
+ name: name.value.trim(), adverts: kind.value === "true", trigger: trigger.value,
+ every_songs: Math.max(1, parseInt(songs.value, 10) || 1), every_mins: Math.max(1, parseInt(mins.value, 10) || 1), on_clock: clock.checked,
+ times: parsed.length ? parsed : r.times, use_ad_list: kind.value === "true" && adList.checked,
+ folder: folder.value.trim(), files: files.value.split("\n").map((s) => s.trim()).filter(Boolean),
+ count: Math.max(1, parseInt(count.value, 10) || 1), pick: pick.value,
+ days: days.map((d) => d.checked), from_hour: +fromH.value, to_hour: +toH.value, from_date: fromD.value, to_date: toD.value,
+ });
+ return saveCfg((c) => {
+ c.ad_rules = c.ad_rules || [];
+ const k = r.id ? c.ad_rules.findIndex((y) => y.id === r.id) : -1;
+ if (k >= 0) c.ad_rules[k] = r;
+ else { r.id = c.ad_rules.reduce((m, y) => Math.max(m, y.id), 0) + 1; c.ad_rules.push(r); }
+ });
+ });
+}
+
+// ------------------------------------------------------------------ AzuraCast
+
+let azTimer = null;
+
+function azReady() { return cfg && cfg.azuracast && cfg.azuracast.base_url && cfg.azuracast.station; }
+
+function renderAzForm() {
+ if (document.activeElement && $("azForm").contains(document.activeElement)) return;
+ const a = cfg.azuracast || {};
+ $("azUrl").value = a.base_url || "";
+ $("azStation").value = a.station || "";
+ $("azKey").value = "";
+ $("azKey").placeholder = a.api_key ? "saved (type to replace)" : "needed for the controls and playlists";
+ $("azOn").checked = !!a.enabled;
+}
+
+$("azForm").addEventListener("submit", (ev) => {
+ ev.preventDefault();
+ saveCfg((c) => {
+ c.azuracast.base_url = $("azUrl").value.trim().replace(/\/+$/, "");
+ c.azuracast.station = $("azStation").value.trim();
+ if ($("azKey").value.trim()) c.azuracast.api_key = $("azKey").value.trim();
+ c.azuracast.enabled = $("azOn").checked;
+ }).then((ok) => { if (ok) { document.activeElement.blur(); renderAzForm(); azLoad(); } });
+});
+
+function az(method, path, body) { return api("azuracast", { method, path, body }); }
+
+async function azLoad() {
+ clearTimeout(azTimer);
+ if (view !== "azuracast") return;
+ const st = $("azStatus");
+ if (!azReady()) {
+ st.textContent = "Set up the connection to AzuraCast below.";
+ $("azPlaylists").replaceChildren();
+ return;
+ }
+ try {
+ const np = await az("GET", "/nowplaying/{st}");
+ const now = np.now_playing || {};
+ const song = now.song || {};
+ st.replaceChildren(
+ el("div", { class: "row wrap" }, el("strong", {}, (np.station && np.station.name) || cfg.azuracast.station),
+ el("span", { class: "badge " + (np.is_online ? "playing" : "off") }, np.is_online ? "online" : "offline"),
+ np.live && np.live.is_live ? el("span", { class: "badge paused" }, "live: " + np.live.streamer_name) : null),
+ el("div", { class: "title" }, song.text || [song.artist, song.title].filter(Boolean).join(" - ") || "—"),
+ el("div", { class: "muted small" }, `${(np.listeners && np.listeners.current) || 0} listening`,
+ np.playing_next && np.playing_next.song ? " · next: " + (np.playing_next.song.text || np.playing_next.song.title) : ""));
+ } catch (e) {
+ st.textContent = "Couldn't reach AzuraCast: " + e.message;
+ }
+ azPlaylists();
+ azTimer = setTimeout(azLoad, document.hidden ? 60000 : 15000);
+}
+
+async function azPlaylists() {
+ const ul = $("azPlaylists");
+ if (!cfg.azuracast.api_key) { ul.replaceChildren(el("li", { class: "muted" }, "An API key is needed to see the station's playlists.")); return; }
+ try {
+ const list = await az("GET", "/station/{st}/playlists");
+ ul.replaceChildren();
+ (Array.isArray(list) ? list : []).forEach((p) => ul.append(el("li", {},
+ el("span", { class: "grow" }, p.name, el("small", {}, [p.type, p.num_songs !== undefined ? p.num_songs + " songs" : "", p.is_enabled ? "on" : "off"].filter(Boolean).join(" · "))),
+ el("button", { onclick: () => az("PUT", `/station/{st}/playlist/${encodeURIComponent(p.id)}/toggle`).then(() => { toast(p.is_enabled ? "Switched off" : "Switched on"); azPlaylists(); }).catch((e) => toast(e.message, true)) },
+ p.is_enabled ? "Switch off" : "Switch on"))));
+ if (!ul.children.length) ul.append(el("li", { class: "muted" }, "The station has no playlists."));
+ } catch (e) {
+ ul.replaceChildren(el("li", { class: "err" }, e.message));
+ }
+}
+
+$("azRefresh").addEventListener("click", azLoad);
+document.querySelectorAll("[data-az]").forEach((b) => b.addEventListener("click", () => {
+ if (!azReady()) return toast("Set up the connection to AzuraCast first", true);
+ if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
+ const [method, path] = b.dataset.az.split(" ");
+ b.disabled = true;
+ az(method, path).then((r) => { toast((r && r.message) || "Done"); setTimeout(azLoad, 1500); })
+ .catch((e) => toast(e.message, true)).finally(() => { b.disabled = false; });
+}));
+
+// ------------------------------------------------------------------ updates
+
+/// "RadioMaster Server 0.3.0 is out" when the server is behind.
+async function checkServer() {
+ try {
+ const info = await api("server");
+ const b = $("update");
+ if (info && info.latest) {
+ b.replaceChildren(`RadioMaster ${info.latest} is out; this server runs ${info.version}. Update it on the server. `,
+ info.release_page ? el("a", { href: info.release_page, target: "_blank", rel: "noopener noreferrer" }, "What's new") : "");
+ b.hidden = false;
+ } else {
+ b.hidden = true;
+ }
+ } catch (e) { /* only for those allowed to see the server's settings */ }
+}
+
+// ------------------------------------------------------------------ polling
+
async function poll() {
const s = await api(`state?rev=${rev}&cfg=${cfgRev}&log=${logRev}&hist=${histRev}`);
live = s.live;
- let rows = false;
+ live.lib_ver = s.lib_ver;
+ let rows = false, cfgChanged = false;
if (s.tabs) { tabs = s.tabs; rev = s.rev; renderTabs(); rows = true; }
if (s.cfg) {
- cfg = s.cfg; cfgRev = s.cfg_rev;
+ cfg = s.cfg; cfgRev = s.cfg_rev; cfgChanged = true;
if (!volumeBusy) $("volume").value = cfg.volume;
encKey = ""; // names may have changed
}
- if (s.log) { logRev = s.log_rev; renderList("log", s.log.slice(-80), (x) => x.time + " " + x.text, (x) => x.error); }
- if (s.history) { histRev = s.hist_rev; renderList("history", s.history.slice(-30), (x) => x.time + " " + x.text); }
+ if (s.log) { logRev = s.log_rev; renderList("log", s.log.slice(-150), (x) => x.time + " " + x.text, (x) => x.error); }
+ if (s.history) { histRev = s.hist_rev; renderList("history", s.history.slice(-60), (x) => x.time + " " + x.text); }
if (rows) renderRows();
renderLive();
+ // what the open view shows changed (not while a dialog is editing it)
+ if ((cfgChanged || rows) && !$("dlg").open && view !== "azuracast" && view !== "library") renderView();
+ if (cfgChanged && view === "azuracast") renderAzForm();
+ if (view === "library" && library && libVer !== s.lib_ver && !libLoading) loadLibrary(true);
$("neterr").textContent = "";
}
@@ -251,10 +907,15 @@ async function loop() {
timer = setTimeout(loop, document.hidden ? 5000 : 1000);
}
+let serverTimer = null;
function start() {
showDesk(true);
+ showView(view);
clearTimeout(timer);
loop();
+ clearInterval(serverTimer);
+ checkServer();
+ serverTimer = setInterval(() => { if (token) checkServer(); }, 30 * 60 * 1000);
}
document.addEventListener("visibilitychange", () => {
diff --git a/docs/SERVER.md b/docs/SERVER.md
index b89a315..3cb5300 100644
--- a/docs/SERVER.md
+++ b/docs/SERVER.md
@@ -42,7 +42,9 @@ encoders. Run `RadioMaster-Server --help` for every option.
| `--data FOLDER` | the data folder (default `~/.config/RadioMaster-Server`, or `%APPDATA%\RadioMaster-Server` on Windows) |
| `--https`, `--no-https` | serve over HTTPS (see [HTTPS](#https)) |
| `--cert FILE`, `--key FILE` | a PEM certificate (chain) and its private key for HTTPS; without them the server makes its own |
+| `--acme DOMAINS`, `--acme-email`, `--acme-staging`, `--acme-http-port N`, `--no-acme` | a free [Let's Encrypt certificate](#lets-encrypt) for these domain names |
| `--portal`, `--no-portal`, `--portal-port N` | the [web portal](#web-portal) and its port (default 9260) |
+| `--tunnel`, `--no-tunnel`, `--tunnel-token TOKEN`, `--cloudflared FILE` | put the portal on the internet with a [Cloudflare Tunnel](#cloudflare-tunnel) |
| `--no-audio` | don't use a sound card: stream only |
| `--list-devices` | list the sound cards |
@@ -75,8 +77,10 @@ HTTPS** (it takes effect when the server restarts). Everything between the apps,
then encrypted, and the port answers plain `http://` only with a pointer to `https://`.
- **Your own certificate** (for example Let's Encrypt's): `--cert /etc/letsencrypt/live/radio.example.com/fullchain.pem --key /etc/letsencrypt/live/radio.example.com/privkey.pem`. The
- app and browsers trust it like any website. The server account must be able to read both files; restart the
- server after the certificate is renewed.
+ app and browsers trust it like any website. The server account must be able to read both files. When the
+ files are renewed (certbot, win-acme…), the server picks up the new certificate within a few minutes, without
+ a restart.
+- **A free certificate from Let's Encrypt, looked after by the server:** see [Let's Encrypt](#lets-encrypt).
- **No certificate:** the server makes a self-signed one the first time (`tls-cert.pem` and `tls-key.pem` in
its data folder) and prints its SHA-256 fingerprint at start-up. The first time an app connects, it shows
the fingerprint and asks you to trust it; check it against the one the server printed (it is also in
@@ -85,11 +89,56 @@ then encrypted, and the port answers plain `http://` only with a pointer to `htt
An address typed without `https://` still finds a server that uses HTTPS.
-**Behind a reverse proxy** (nginx, Caddy, IIS…): do HTTPS in one place. Either the proxy does it and the
-server's HTTPS is off (`--setup --no-https`, plus `--bind 127.0.0.1` when the proxy is on the same machine), or
-the proxy forwards to `https://` on the server. A proxy that sends plain `http://` to a server with HTTPS on gets
-a redirect to `https://` every time, which browsers report as "too many redirects". Examples for nginx, Caddy
-and IIS are in the [online documentation](https://radiomaster.dovafm.com/server/docs/#proxy).
+### Let's Encrypt
+The server can get its own certificate from Let's Encrypt and renew it by itself (30 days before it runs out),
+so browsers and apps trust it with no warning. You need a domain name (e.g. `radio.example.com`) that points
+at the server, and **port 80** of that name has to reach the server (forward it on your router), because
+Let's Encrypt checks the name by fetching a file from `http://radio.example.com/.well-known/acme-challenge/…`.
+
+```
+RadioMaster-Server --setup --acme radio.example.com --acme-email you@example.com
+```
+
+or in the app: **File → Server settings… → Use HTTPS → Get a free certificate from Let's Encrypt**, with the
+domain names (comma-separated) and an optional email. Until the first certificate arrives the server uses its
+self-signed one; the new one is swapped in without a restart, and the settings window shows how it went and
+when the certificate runs out. Tick *staging* to try it out first (Let's Encrypt limits how often real
+certificates can be asked for; staging certificates aren't trusted by browsers).
+
+On Linux, listening on port 80 needs root or `sudo setcap cap_net_bind_service=+ep RadioMaster-Server`. Port
+80 also sends browsers to the `https://` portal. If something else (IIS, nginx) already uses port 80, pass
+`/.well-known/acme-challenge/` on to the server's port and start it with `--acme-http-port 0`; or let that
+program get the certificate (win-acme, certbot) and give the files to the server with `--cert` and `--key`.
+
+### Behind a reverse proxy (IIS, nginx, Caddy)
+A proxy that serves HTTPS itself can talk to the server over plain HTTP. Either:
+
+- turn the server's HTTPS **off** and let the proxy do it (simplest), or
+- keep it on: plain requests from a proxy on the same machine or the local network that says the browser used
+ HTTPS (`X-Forwarded-Proto: https`, IIS's `X-ARR-SSL`, Cloudflare's `CF-Visitor`) are served as they are instead
+ of being sent to `https://`. (From other addresses those headers are ignored, so they can't be faked.)
+ (Before 0.2.9 that redirect sent browsers round in a loop, `ERR_TOO_MANY_REDIRECTS`.)
+
+**IIS** (URL Rewrite + Application Request Routing): add a reverse-proxy rule to `http://localhost:9260` for
+the portal (or 9259 for the apps), keep *Reverse rewrite host in response headers* off, and set the ARR proxy's
+*Response buffer threshold* to 0 so the station's sound reaches the app straight away. **nginx:** set
+`proxy_buffering off;` and `proxy_set_header X-Forwarded-Proto $scheme;`. More examples (nginx, Caddy, IIS) are in
+the [online documentation](https://radiomaster.dovafm.com/server/docs/#proxy).
+
+### Cloudflare Tunnel
+A Cloudflare Tunnel puts the web portal on the internet with no port forwarding and no certificate to look
+after. Install Cloudflare's `cloudflared` on the server (or put it next to `RadioMaster-Server`), then either:
+
+- **Your own domain:** in the Cloudflare dashboard, *Zero Trust → Networks → Tunnels → Create a tunnel*, add a
+ public hostname (e.g. `radio.example.com`) with the service `http://localhost:9260`, and copy the tunnel's
+ token. Set `CLOUDFLARE_TUNNEL_TOKEN` (or use `--tunnel-token
`), or paste it in **Server settings →
+ Cloudflare Tunnel**. It is kept in `tunnel-token` in the data folder, readable by its owner only, not in
+ `server.json`.
+- **Just try it:** `--tunnel` with no token gives a random `https://….trycloudflare.com` address (it changes
+ each time the tunnel starts); it is printed in the log and shown in Server settings.
+
+The server starts `cloudflared`, starts it again if it stops, and stops it on exit. Switching the tunnel on
+or off in Server settings takes effect at once. The desktop app can connect through the tunnel address too.
## Web portal
The web portal runs the station from a browser on any computer, tablet or phone, with nothing to install.
@@ -97,9 +146,22 @@ Turn it on with `--setup --portal` (choose the port with `--portal-port`, defaul
**File → Server settings… → Web portal** (after a restart). Open `http://:9260` (or `https://` with
HTTPS on) and sign in with the server's username and password.
-It shows what's on air with its progress and levels, play / pause / stop / next / previous, the volume, the
-playlists (double-click a row to play it), the streams with start and stop, recently played and the station
-log. The portal needs a port of its own; allow it through the firewall like the main port.
+It works on phones (the sections sit along the bottom of the screen) as well as computers:
+
+- **Studio:** what's on air with its progress and levels, play / pause / stop / next / previous, the volume, the
+ playlists (double-click a row to play it, ✕ to take it out) and the streams with start and stop.
+- **Library:** search the server's library and add songs to any playlist, at the end or to play next; rescan.
+- **Carts:** the cart wall; tap a pad to play it, or *Edit pads* to choose each pad's sound and name.
+- **Generator:** make a playlist from a template, keep the on-air playlist filled, and edit the templates and
+ categories (folders are the server's).
+- **Adverts:** the advert and jingle rules: switch them on and off, play one now, add and edit them.
+- **AzuraCast:** the station's now playing and listeners, skip, restart, start and stop broadcasting and the
+ AutoDJ, switch AzuraCast playlists on and off, and the connection settings (the calls go
+ through the server, with the API key from Settings → AzuraCast).
+- **Log:** recently played and the station log.
+
+A banner says when a newer RadioMaster release is out than the server runs. The portal needs a port of its
+own; allow it through the firewall like the main port.
## Connect from the desktop app
**From the login screen:** **Connect to Remote Server…** below the sign-in box connects without a local
diff --git a/lang/template.json b/lang/template.json
index 823a22f..3b187f4 100644
--- a/lang/template.json
+++ b/lang/template.json
@@ -101,6 +101,7 @@
"Bitrate (kbps)": "",
"Broadcast / encoders": "",
"Browse": "",
+ "Browsers and apps trust it like any website. Port 80 of each domain name has to reach the server; it is renewed on its own.": "",
"COMING UP NEXT": "",
"Can't be read (damaged or not audio)": "",
"Can't show the waveform": "",
@@ -109,6 +110,7 @@
"Categories": "",
"Categories (what each pool of tracks is made from)": "",
"Certificate SHA-256:": "",
+ "Certificate valid until": "",
"Change the server password": "",
"Change ticked to": "",
"Check for updates when RadioMaster starts": "",
@@ -172,6 +174,7 @@
"Display name": "",
"Do": "",
"Do it now, as a test": "",
+ "Domain names": "",
"Double-click a folder to open it, or a file to add it to the playlist you're viewing.": "",
"Download it and add it to the playlist you're viewing": "",
"Download it and put it after the selected row": "",
@@ -187,6 +190,7 @@
"Each track is analysed: silence is skipped, cold endings get almost no overlap, and songs that fade out let the next one start as the outro dies away. Cues you set in the track editor always win.": "",
"Edit selected track": "",
"Edit this playlist freely. Play any row (or right-click the tab) to put it on air.": "",
+ "Email (optional)": "",
"Empty file (0 bytes)": "",
"Enable": "",
"Enable / disable": "",
@@ -244,6 +248,7 @@
"Generate a new playlist from a template": "",
"Generator": "",
"Genre": "",
+ "Get a free certificate from Let's Encrypt": "",
"Go": "",
"HTTP GET URL": "",
"HTTPS, certificate from a public authority": "",
@@ -339,6 +344,7 @@
"MIDI only": "",
"MP3 (built in)": "",
"Make a jingle rule in the Advertisement scheduler first, or add a Jingles category": "",
+ "Make a tunnel in the Cloudflare dashboard (Zero Trust → Networks → Tunnels) for your own domain, and point it at http://localhost and the portal port. Without a token you get a random trycloudflare.com address.": "",
"Make everything larger or smaller if the text is hard to read.": "",
"Make this the primary output": "",
"Make-up gain": "",
@@ -383,6 +389,7 @@
"No folders or audio files here.": "",
"No now-playing data yet.": "",
"No plugins yet. \"Open plugins folder\" adds a working example to start from.": "",
+ "No port forwarding needed. The server runs Cloudflare's cloudflared program, which has to be installed on it.": "",
"No rule plays from this list yet — in a rule, choose \"From: the Adverts list\".": "",
"No rules yet. Add an advert or jingle rule above.": "",
"No sound": "",
@@ -474,6 +481,7 @@
"Profile:": "",
"Projected start times": "",
"Put a playlist tab on air": "",
+ "Put the web portal on the internet with a Cloudflare Tunnel": "",
"Queue all": "",
"Queue is empty. Add music to the playlist.": "",
"Queue next": "",
@@ -670,6 +678,7 @@
"Translate RadioMaster: export the template, fill in the right-hand side of each line, save it as e.g. Deutsch.json in the lang folder, then pick it here. Share it on GitHub so everyone gets it.": "",
"Trim silence": "",
"Trust and connect": "",
+ "Tunnel token": "",
"Turn on": "",
"Turn on Listen to MIDI controllers first": "",
"Turn the level down before boosting, so loud tracks don't clip": "",
@@ -683,6 +692,7 @@
"Upload folder": "",
"Use": "",
"Use HTTPS": "",
+ "Use Let's Encrypt's staging server (for trying it out)": "",
"Use for encoder #0": "",
"User": "",
"Username": "",
@@ -804,6 +814,7 @@
"new folder name": "",
"no": "",
"none": "",
+ "none: a quick tunnel": "",
"not found (only needed for formats other than MP3)": "",
"not on air": "",
"of each track (0 = off)": "",
@@ -824,6 +835,7 @@
"preset name": "",
"random": "",
"rows": "",
+ "saved (type to replace)": "",
"search requestable songs": "",
"short name or ID": "",
"slower ↔ faster": "",
diff --git a/src/acme.rs b/src/acme.rs
new file mode 100644
index 0000000..24279f3
--- /dev/null
+++ b/src/acme.rs
@@ -0,0 +1,500 @@
+//! Free HTTPS certificates from Let's Encrypt (or another ACME authority) for RadioMaster Server.
+//!
+//! The server asks for a certificate for its domain names, proves it controls them by answering
+//! Let's Encrypt's check at http:///.well-known/acme-challenge/… (the "http-01" challenge:
+//! port 80 has to reach the server), and renews the certificate on its own well before it runs out.
+//! The new certificate is swapped in while the server runs.
+//!
+//! This follows RFC 8555: requests are JSON Web Signatures made with an ECDSA P-256 account key, which
+//! is kept in the data folder with the account's address.
+
+use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64U, Engine as _};
+use parking_lot::Mutex;
+use ring::rand::SystemRandom;
+use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::{
+ collections::HashMap,
+ path::{Path, PathBuf},
+ sync::Arc,
+ thread,
+ time::Duration,
+};
+
+pub const LETS_ENCRYPT: &str = "https://acme-v02.api.letsencrypt.org/directory";
+pub const LETS_ENCRYPT_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
+
+/// token → key authorization, answered at /.well-known/acme-challenge/ while a check runs.
+pub type Challenges = Arc>>;
+
+/// How the certificate is getting on, for the app's Server settings and the log.
+#[derive(Clone, Default, Debug)]
+pub struct Status {
+ /// "", "working", "ok" or "error"
+ pub state: String,
+ pub message: String,
+}
+
+pub type Shared = Arc>;
+
+/// What to ask for.
+#[derive(Clone, Debug)]
+pub struct Order {
+ pub domains: Vec,
+ pub email: String,
+ /// the authority's directory address (Let's Encrypt, its staging server, or another)
+ pub directory: String,
+}
+
+/// "radio.example.com, www.radio.example.com" → the names, tidied.
+pub fn domains(list: &str) -> Vec {
+ list.split([',', ' ', ';'])
+ .map(|d| d.trim().trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/').to_ascii_lowercase())
+ .filter(|d| !d.is_empty())
+ .collect()
+}
+
+/// account.json in the data folder: the account key (PKCS#8, base64) and its address at the authority.
+#[derive(Serialize, Deserialize, Default)]
+#[serde(default)]
+struct Account {
+ directory: String,
+ key: String,
+ kid: String,
+}
+
+struct Client {
+ agent: ureq::Agent,
+ key: EcdsaKeyPair,
+ rng: SystemRandom,
+ kid: String,
+ nonce: Option,
+ new_nonce: String,
+}
+
+/// A problem document from the authority, or a network error.
+fn problem(e: ureq::Error) -> String {
+ match e {
+ ureq::Error::Status(code, r) => {
+ let v: Value = r.into_json().unwrap_or_default();
+ let detail = v["detail"].as_str().unwrap_or("");
+ let sub: Vec = v["subproblems"]
+ .as_array()
+ .map(|a| a.iter().filter_map(|p| p["detail"].as_str().map(String::from)).collect())
+ .unwrap_or_default();
+ let kind = v["type"].as_str().unwrap_or("").trim_start_matches("urn:ietf:params:acme:error:");
+ let mut msg = format!("HTTP {code}{}", if kind.is_empty() { String::new() } else { format!(" {kind}") });
+ if !detail.is_empty() {
+ msg += &format!(": {detail}");
+ }
+ for s in sub {
+ msg += &format!("; {s}");
+ }
+ msg
+ }
+ e => e.to_string(),
+ }
+}
+
+impl Client {
+ fn jwk(&self) -> Value {
+ // uncompressed point: 0x04 | x | y
+ let p = self.key.public_key().as_ref();
+ json!({ "crv": "P-256", "kty": "EC", "x": B64U.encode(&p[1..33]), "y": B64U.encode(&p[33..65]) })
+ }
+
+ /// RFC 7638: SHA-256 of the key's required members in this exact order, without spaces.
+ fn thumbprint(&self) -> String {
+ use sha2::Digest;
+ let j = self.jwk();
+ let text =
+ format!(r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#, j["x"].as_str().unwrap_or(""), j["y"].as_str().unwrap_or(""));
+ B64U.encode(sha2::Sha256::digest(text.as_bytes()))
+ }
+
+ fn nonce(&mut self) -> Result {
+ if let Some(n) = self.nonce.take() {
+ return Ok(n);
+ }
+ let r = self.agent.head(&self.new_nonce).call().map_err(problem)?;
+ r.header("Replay-Nonce").map(String::from).ok_or_else(|| "no nonce from the authority".to_string())
+ }
+
+ /// A signed POST. `payload` None is a "POST-as-GET" (an empty payload).
+ fn post(&mut self, url: &str, payload: Option<&Value>) -> Result {
+ let mut tries = 0;
+ loop {
+ tries += 1;
+ let mut protected = json!({ "alg": "ES256", "nonce": self.nonce()?, "url": url });
+ if self.kid.is_empty() {
+ protected["jwk"] = self.jwk();
+ } else {
+ protected["kid"] = json!(self.kid);
+ }
+ let protected = B64U.encode(protected.to_string());
+ let payload = payload.map(|p| B64U.encode(p.to_string())).unwrap_or_default();
+ let sig = self.key.sign(&self.rng, format!("{protected}.{payload}").as_bytes()).map_err(|_| "signing failed".to_string())?;
+ let body = json!({ "protected": protected, "payload": payload, "signature": B64U.encode(sig.as_ref()) });
+ let r = self.agent.post(url).set("Content-Type", "application/jose+json").send_string(&body.to_string());
+ let nonce = match &r {
+ Ok(r) => r.header("Replay-Nonce"),
+ Err(ureq::Error::Status(_, r)) => r.header("Replay-Nonce"),
+ Err(_) => None,
+ };
+ self.nonce = nonce.map(String::from);
+ match r {
+ Ok(r) => return Ok(r),
+ // a stale nonce is normal now and then: the answer carries a fresh one
+ Err(ureq::Error::Status(400, r)) if tries < 3 => {
+ let text = r.into_string().unwrap_or_default();
+ if !text.contains("badNonce") {
+ return Err(format!("HTTP 400: {text}"));
+ }
+ }
+ Err(e) => return Err(problem(e)),
+ }
+ }
+ }
+
+ fn post_json(&mut self, url: &str, payload: Option<&Value>) -> Result<(Value, Option), String> {
+ let r = self.post(url, payload)?;
+ let location = r.header("Location").map(String::from);
+ Ok((r.into_json().map_err(|e| e.to_string())?, location))
+ }
+
+ /// POST-as-GET `url` until its status is no longer pending/processing.
+ fn wait(&mut self, url: &str, what: &str) -> Result {
+ for _ in 0..90 {
+ let (v, _) = self.post_json(url, None)?;
+ match v["status"].as_str().unwrap_or("") {
+ "pending" | "processing" => thread::sleep(Duration::from_secs(2)),
+ _ => return Ok(v),
+ }
+ }
+ Err(format!("the {what} took too long"))
+ }
+}
+
+/// Get a certificate. Returns (certificate chain PEM, private key PEM).
+pub fn obtain(
+ order: &Order,
+ dir: &Path,
+ agent: ureq::Agent,
+ challenges: &Challenges,
+ say: &dyn Fn(String),
+) -> Result<(String, String), String> {
+ if order.domains.is_empty() {
+ return Err("no domain name given".into());
+ }
+ let d: Value = agent.get(&order.directory).call().map_err(problem)?.into_json().map_err(|e| e.to_string())?;
+ let url = |k: &str| d[k].as_str().map(String::from).ok_or_else(|| format!("the authority's directory has no {k}"));
+ let (new_nonce, new_account, new_order) = (url("newNonce")?, url("newAccount")?, url("newOrder")?);
+
+ // the account: made once per authority and kept
+ let path = dir.join("acme-account.json");
+ let mut acct: Account = std::fs::read_to_string(&path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
+ let rng = SystemRandom::new();
+ if acct.key.is_empty() || acct.directory != order.directory {
+ let pkcs8 =
+ EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).map_err(|_| "can't make an account key".to_string())?;
+ acct = Account { directory: order.directory.clone(), key: B64U.encode(pkcs8.as_ref()), kid: String::new() };
+ }
+ let der = B64U.decode(&acct.key).map_err(|e| e.to_string())?;
+ let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &der, &rng).map_err(|e| format!("the account key: {e}"))?;
+ let mut c = Client { agent, key, rng, kid: String::new(), nonce: None, new_nonce };
+ // signing up again with the same key just finds the account
+ let mut reg = json!({ "termsOfServiceAgreed": true });
+ if !order.email.trim().is_empty() {
+ reg["contact"] = json!([format!("mailto:{}", order.email.trim())]);
+ }
+ let (_, kid) = c.post_json(&new_account, Some(®))?;
+ c.kid = kid.ok_or("the authority didn't give an account address")?;
+ acct.kid = c.kid.clone();
+ crate::tls::write_private(&path, &serde_json::to_string_pretty(&acct).unwrap_or_default()).map_err(|e| e.to_string())?;
+
+ let ids: Vec = order.domains.iter().map(|d| json!({ "type": "dns", "value": d })).collect();
+ let (o, order_url) = c.post_json(&new_order, Some(&json!({ "identifiers": ids })))?;
+ let order_url = order_url.ok_or("the authority didn't give an order address")?;
+ let thumb = c.thumbprint();
+ let authz: Vec =
+ o["authorizations"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
+ let mut tokens = vec![];
+ let result = (|| {
+ for a in &authz {
+ let (v, _) = c.post_json(a, None)?;
+ let domain = v["identifier"]["value"].as_str().unwrap_or("?").to_string();
+ if v["status"] == "valid" {
+ continue;
+ }
+ let ch = v["challenges"].as_array().and_then(|l| l.iter().find(|x| x["type"] == "http-01")).ok_or_else(|| {
+ format!("{domain}: the authority offers no http-01 check (wildcard names need DNS checks, which aren't supported)")
+ })?;
+ let token = ch["token"].as_str().ok_or("a check without a token")?.to_string();
+ let ch_url = ch["url"].as_str().ok_or("a check without an address")?.to_string();
+ challenges.lock().insert(token.clone(), format!("{token}.{thumb}"));
+ tokens.push(token);
+ say(format!("Let's Encrypt: checking http://{domain}/.well-known/acme-challenge/…"));
+ c.post_json(&ch_url, Some(&json!({})))?;
+ let done = c.wait(a, "domain check")?;
+ if done["status"] != "valid" {
+ let why = done["challenges"]
+ .as_array()
+ .and_then(|l| l.iter().find_map(|x| x["error"]["detail"].as_str().map(String::from)))
+ .unwrap_or_else(|| done["status"].as_str().unwrap_or("failed").to_string());
+ return Err(format!("{domain}: {why} (port 80 of this name has to reach this server)"));
+ }
+ }
+ Ok(())
+ })();
+ {
+ let mut ch = challenges.lock();
+ for t in &tokens {
+ ch.remove(t);
+ }
+ }
+ result?;
+
+ // the certificate's own key, and the request for it
+ let kp = rcgen::KeyPair::generate().map_err(|e| e.to_string())?;
+ let csr = rcgen::CertificateParams::new(order.domains.clone())
+ .and_then(|p| p.serialize_request(&kp))
+ .map_err(|e| format!("can't make the certificate request: {e}"))?;
+ let finalize = o["finalize"].as_str().ok_or("the order has no finalize address")?.to_string();
+ let (f, _) = c.post_json(&finalize, Some(&json!({ "csr": B64U.encode(csr.der()) })))?;
+ let done = if f["status"] == "valid" { f } else { c.wait(&order_url, "certificate")? };
+ if done["status"] != "valid" {
+ return Err(format!("the certificate wasn't issued ({})", done["status"].as_str().unwrap_or("?")));
+ }
+ let cert_url = done["certificate"].as_str().ok_or("the order has no certificate address")?.to_string();
+ let chain = c.post(&cert_url, None)?.into_string().map_err(|e| e.to_string())?;
+ if !chain.contains("BEGIN CERTIFICATE") {
+ return Err("the authority sent something that isn't a certificate".into());
+ }
+ Ok((chain, kp.serialize_pem()))
+}
+
+/// Renew with 30 days left (a third of the 90 days a Let's Encrypt certificate lasts).
+fn due(info: &crate::tls::CertInfo) -> bool {
+ info.not_after.is_none_or(|end| end - chrono::Utc::now().timestamp() < 30 * 86400)
+}
+
+/// Keep a Let's Encrypt certificate in `certs`: get one now if there is none (or it runs out soon),
+/// then look twice a day. `want` gives the order (it can change in the app while the server runs);
+/// None while it is switched off.
+pub fn keep(
+ certs: Arc,
+ dir: PathBuf,
+ want: Box Option + Send>,
+ challenges: Challenges,
+ status: Shared,
+ say: Box,
+) {
+ thread::spawn(move || {
+ let mut failures = 0u32;
+ loop {
+ let Some(order) = want() else {
+ // switched off (it can be switched on again in the app)
+ *status.lock() = Status::default();
+ thread::sleep(Duration::from_secs(30));
+ continue;
+ };
+ let info = certs.info();
+ let (cf, kf) = crate::tls::acme_files(&dir);
+ let names_changed = !info.self_signed && info.cert_file == cf && covers(&cf) != order.domains;
+ if info.cert_file != cf || due(&info) || names_changed {
+ *status.lock() = Status { state: "working".into(), message: format!("asking for {}", order.domains.join(", ")) };
+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build();
+ let r = obtain(&order, &dir, agent, &challenges, &*say).and_then(|(chain, key)| {
+ std::fs::write(&cf, chain).map_err(|e| e.to_string())?;
+ crate::tls::write_private(&kf, &key).map_err(|e| e.to_string())?;
+ std::fs::write(names_file(&cf), order.domains.join("\n")).map_err(|e| e.to_string())?;
+ certs.load(&cf, &kf, false)
+ });
+ match r {
+ Ok(i) => {
+ failures = 0;
+ let until =
+ i.not_after.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string());
+ let msg = format!("certificate for {} until {}", order.domains.join(", "), until.unwrap_or_default());
+ say(format!("Let's Encrypt: {msg}"));
+ *status.lock() = Status { state: "ok".into(), message: msg };
+ }
+ Err(e) => {
+ failures += 1;
+ say(format!("Let's Encrypt: {e}"));
+ *status.lock() = Status { state: "error".into(), message: e };
+ }
+ }
+ } else if status.lock().state.is_empty() {
+ let until = info.not_after.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string());
+ *status.lock() = Status {
+ state: "ok".into(),
+ message: format!("certificate for {} until {}", order.domains.join(", "), until.unwrap_or_default()),
+ };
+ }
+ // after a failure try again sooner (but not so often that Let's Encrypt's limits are hit)
+ let wait = if failures == 0 { 12 * 3600 } else { (failures.min(6) as u64) * 1800 };
+ let mut slept = 0;
+ while slept < wait {
+ thread::sleep(Duration::from_secs(30));
+ slept += 30;
+ // the domains changed in the app: start over at once
+ match want() {
+ Some(o) if o.domains == order.domains && o.directory == order.directory => {}
+ _ => break,
+ }
+ }
+ }
+ });
+}
+
+fn names_file(cert: &Path) -> PathBuf {
+ cert.with_extension("names")
+}
+
+/// The domain names the saved certificate was asked for.
+fn covers(cert: &Path) -> Vec {
+ std::fs::read_to_string(names_file(cert)).map(|s| s.lines().map(String::from).filter(|l| !l.is_empty()).collect()).unwrap_or_default()
+}
+
+/// Answer port 80: Let's Encrypt's checks, and everything else sent to the https:// address
+/// (the web portal when it is on). Returns the address bound.
+pub fn serve_http(bind: &str, port: u16, challenges: Challenges, https_port: u16) -> Result {
+ let server = tiny_http::Server::http((bind, port)).map_err(|e| format!("port {port}: {e}"))?;
+ let addr = server.server_addr().to_ip().ok_or("not an IP address")?;
+ let server = Arc::new(server);
+ for _ in 0..2 {
+ let (server, challenges) = (server.clone(), challenges.clone());
+ thread::spawn(move || {
+ while let Ok(req) = server.recv() {
+ let url = req.url().to_string();
+ let reply = match url.strip_prefix("/.well-known/acme-challenge/").and_then(|t| challenges.lock().get(t).cloned()) {
+ Some(answer) => tiny_http::Response::from_string(answer).with_status_code(200),
+ None => {
+ let host =
+ req.headers().iter().find(|h| h.field.equiv("Host")).map(|h| h.value.as_str().to_string()).unwrap_or_default();
+ let name = host_name(&host);
+ if name.is_empty() || !url.starts_with('/') {
+ tiny_http::Response::from_string("Not found").with_status_code(404)
+ } else {
+ let port = if https_port == 443 { String::new() } else { format!(":{https_port}") };
+ let to = format!("https://{name}{port}{url}");
+ tiny_http::Response::from_string("")
+ .with_status_code(301)
+ .with_header(tiny_http::Header::from_bytes(&b"Location"[..], to.as_bytes()).unwrap())
+ }
+ }
+ };
+ let _ = req.respond(reply);
+ }
+ });
+ }
+ Ok(addr)
+}
+
+/// "radio.example.com:80" → "radio.example.com" (only safe characters).
+fn host_name(host: &str) -> String {
+ let name = if host.starts_with('[') {
+ host.split(']').next().map(|h| format!("{h}]")).unwrap_or_default()
+ } else {
+ host.split(':').next().unwrap_or("").to_string()
+ };
+ if name.chars().all(|c| c.is_ascii_alphanumeric() || ".-[]:".contains(c)) {
+ name
+ } else {
+ String::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn domain_list_is_tidied() {
+ assert_eq!(domains(" Radio.Example.com, https://www.example.com/ ;"), vec!["radio.example.com", "www.example.com"]);
+ assert!(domains(" ").is_empty());
+ assert_eq!(host_name("radio.example.com:80"), "radio.example.com");
+ assert_eq!(host_name("[::1]:80"), "[::1]");
+ assert_eq!(host_name("evil.com\r\nX: y"), "");
+ }
+
+ #[test]
+ fn requests_are_signed_with_the_account_key() {
+ let rng = SystemRandom::new();
+ let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
+ let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap();
+ let public = key.public_key().as_ref().to_vec();
+ let c = Client { agent: ureq::agent(), key, rng: SystemRandom::new(), kid: String::new(), nonce: None, new_nonce: String::new() };
+ // the JWK carries the same point, and the thumbprint is a base64url SHA-256
+ let j = c.jwk();
+ let mut point = vec![4u8];
+ point.extend(B64U.decode(j["x"].as_str().unwrap()).unwrap());
+ point.extend(B64U.decode(j["y"].as_str().unwrap()).unwrap());
+ assert_eq!(point, public);
+ assert_eq!(B64U.decode(c.thumbprint()).unwrap().len(), 32);
+ // ES256 signatures are r|s (64 bytes) and check out against the public key
+ let sig = c.key.sign(&c.rng, b"a.b").unwrap();
+ assert_eq!(sig.as_ref().len(), 64);
+ ring::signature::UnparsedPublicKey::new(&ring::signature::ECDSA_P256_SHA256_FIXED, &public).verify(b"a.b", sig.as_ref()).unwrap();
+ }
+}
+
+/// Against Pebble, Let's Encrypt's test server, when it is installed: PEBBLE=/path/to/pebble and
+/// PEBBLE_DIR=its source folder (with test/certs). Pebble checks http-01 on port 5002.
+#[cfg(test)]
+mod pebble {
+ use super::*;
+ use rustls::pki_types::pem::PemObject;
+
+ #[test]
+ fn gets_a_certificate_from_pebble() {
+ let (Ok(bin), Ok(src)) = (std::env::var("PEBBLE"), std::env::var("PEBBLE_DIR")) else {
+ eprintln!("PEBBLE / PEBBLE_DIR not set: skipped");
+ return;
+ };
+ let src = PathBuf::from(src);
+ let mut child = std::process::Command::new(bin)
+ .args(["-config", "test/config/pebble-config.json"])
+ .current_dir(&src)
+ .env("PEBBLE_VA_NOSLEEP", "1")
+ .stdout(std::process::Stdio::null())
+ .spawn()
+ .unwrap();
+ thread::sleep(Duration::from_secs(2));
+ let mut roots = rustls::RootCertStore::empty();
+ for c in rustls::pki_types::CertificateDer::pem_file_iter(src.join("test/certs/pebble.minica.pem")).unwrap() {
+ roots.add(c.unwrap()).unwrap();
+ }
+ let tls = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
+ .with_safe_default_protocol_versions()
+ .unwrap()
+ .with_root_certificates(roots)
+ .with_no_client_auth();
+ let agent = ureq::AgentBuilder::new().tls_config(Arc::new(tls)).build();
+ let challenges: Challenges = Default::default();
+ serve_http("127.0.0.1", 5002, challenges.clone(), 443).unwrap();
+ let dir = std::env::temp_dir().join(format!("rm-acme-test-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let order =
+ Order { domains: vec!["localhost".into()], email: "dj@example.com".into(), directory: "https://localhost:14000/dir".into() };
+ let r = obtain(&order, &dir, agent.clone(), &challenges, &|m| eprintln!("{m}"));
+ // a second time with the same account
+ let again = r.as_ref().ok().map(|_| obtain(&order, &dir, agent, &challenges, &|_| {}));
+ let _ = child.kill();
+ let _ = child.wait();
+ let (chain, key) = r.unwrap();
+ assert!(again.unwrap().is_ok());
+ assert!(chain.contains("BEGIN CERTIFICATE") && key.contains("PRIVATE KEY"));
+ assert!(challenges.lock().is_empty(), "the checks are cleared afterwards");
+ // the server can use it straight away
+ let (cf, kf) = crate::tls::acme_files(&dir);
+ std::fs::write(&cf, &chain).unwrap();
+ std::fs::write(&kf, &key).unwrap();
+ let t = crate::tls::server_tls("", "", &dir, true).unwrap();
+ let info = t.info();
+ assert!(!info.self_signed);
+ assert!(info.not_after.unwrap() > chrono::Utc::now().timestamp());
+ }
+}
diff --git a/src/app.rs b/src/app.rs
index d7a4794..03a9770 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -496,6 +496,12 @@ fn source_combo(ui: &mut egui::Ui, id: impl std::hash::Hash, cur: &mut String, c
});
}
+/// Something a background thread calls to have the window drawn again.
+fn repainter(ctx: &egui::Context) -> impl Fn() + Send + 'static {
+ let ctx = ctx.clone();
+ move || ctx.request_repaint()
+}
+
fn host_of(url: &str) -> String {
let u = url.trim().trim_start_matches("https://").trim_start_matches("http://");
u.split('/').next().unwrap_or("").split(':').next().unwrap_or("").to_string()
@@ -1097,7 +1103,7 @@ impl App {
ui.close_menu();
}
if ui.button(tr("Check for updates…")).clicked() {
- crate::updater::check(self.updates.clone(), ctx.clone(), true);
+ crate::updater::check(self.updates.clone(), repainter(ctx), true);
self.show_update = true;
ui.close_menu();
}
@@ -3346,7 +3352,7 @@ impl App {
}
});
if start_download {
- crate::updater::download(self.updates.clone(), ctx.clone());
+ crate::updater::download(self.updates.clone(), repainter(ctx));
}
if let Some(p) = install {
match crate::updater::run_installer(&p) {
@@ -3516,7 +3522,7 @@ impl eframe::App for App {
if core.cfg.check_updates && self.update_next.is_none_or(|t| std::time::Instant::now() >= t) {
// at start-up, then every 6 hours for stations that run for weeks
self.update_next = Some(std::time::Instant::now() + Duration::from_secs(6 * 3600));
- crate::updater::check(self.updates.clone(), ctx.clone(), false);
+ crate::updater::check(self.updates.clone(), repainter(ctx), false);
}
self.update_window(ctx, &mut core);
self.apply_tab_ops(&mut core);
diff --git a/src/server.rs b/src/server.rs
index 2545a18..91be5fd 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -14,11 +14,14 @@
//! GET /api/v1/listen → the on-air mix as an endless MP3 stream (what the app hears)
//! POST /api/v1/mic MP3 bytes the app's mic, a slice at a time; heard on air until mic/off
//! POST /api/v1/mic/off
+//! POST /api/v1/azuracast {method, path, body} a call to the station's AzuraCast, with the server's key
//!
//! Every call but ping and login needs `Authorization: Bearer `.
//!
-//! With HTTPS on, the same API is served over TLS (see tls.rs). The optional web portal is a page on
-//! its own port that signs in and uses this same API from the browser.
+//! With HTTPS on, the same API is served over TLS (see tls.rs), with a certificate that is given, made
+//! by the server, or got from Let's Encrypt (acme.rs). The optional web portal is a page on its own
+//! port that signs in and uses this same API from the browser; it can be put on the internet with a
+//! Cloudflare Tunnel (tunnel.rs).
use crate::config::data_dir;
use crate::playout::sync::{self, digest, Cmd, ServerInfo, ServerUpdate, Snapshot, TabsWire};
@@ -58,6 +61,25 @@ pub struct ServerCfg {
/// the web portal: a browser page for running the station
pub portal: bool,
pub portal_port: u16,
+ /// HTTPS with a free certificate from Let's Encrypt for these domain names (comma-separated)
+ pub acme: bool,
+ pub acme_domains: String,
+ /// where Let's Encrypt writes about the certificate (optional)
+ pub acme_email: String,
+ /// Let's Encrypt's staging server (for trying it out: browsers don't trust its certificates)
+ pub acme_staging: bool,
+ /// another ACME authority's directory address (empty: Let's Encrypt)
+ pub acme_directory: String,
+ /// the plain-HTTP port for Let's Encrypt's checks (0: none, e.g. a proxy in front passes them on)
+ pub acme_http_port: u16,
+ /// put the web portal on the internet through a Cloudflare Tunnel
+ pub tunnel: bool,
+ /// the tunnel's token from the Cloudflare dashboard (empty: a quick *.trycloudflare.com tunnel).
+ /// Kept in its own owner-only file, tunnel-token, not in server.json.
+ #[serde(skip)]
+ pub tunnel_token: String,
+ /// the cloudflared program (empty: next to the server, or on the PATH)
+ pub cloudflared: String,
}
impl Default for ServerCfg {
@@ -75,6 +97,15 @@ impl Default for ServerCfg {
key_file: String::new(),
portal: false,
portal_port: sync::DEFAULT_PORTAL_PORT,
+ acme: false,
+ acme_domains: String::new(),
+ acme_email: String::new(),
+ acme_staging: false,
+ acme_directory: String::new(),
+ acme_http_port: 80,
+ tunnel: false,
+ tunnel_token: String::new(),
+ cloudflared: String::new(),
}
}
}
@@ -83,15 +114,41 @@ impl ServerCfg {
pub fn path() -> std::path::PathBuf {
data_dir().join("server.json")
}
+ fn token_path() -> std::path::PathBuf {
+ data_dir().join("tunnel-token")
+ }
pub fn load() -> Option {
- std::fs::read_to_string(Self::path()).ok().and_then(|s| serde_json::from_str(&s).ok())
+ let mut c: Self = std::fs::read_to_string(Self::path()).ok().and_then(|s| serde_json::from_str(&s).ok())?;
+ c.tunnel_token = std::fs::read_to_string(Self::token_path()).map(|t| t.trim().to_string()).unwrap_or_default();
+ Some(c)
}
pub fn save(&self) -> std::io::Result<()> {
+ if self.tunnel_token.is_empty() {
+ let _ = std::fs::remove_file(Self::token_path());
+ } else {
+ crate::tls::write_private(&Self::token_path(), &self.tunnel_token)?;
+ }
std::fs::write(Self::path(), serde_json::to_string_pretty(self).unwrap_or_default())
}
pub fn ready(&self) -> bool {
!self.user.is_empty() && !self.password_hash.is_empty()
}
+
+ /// What to ask Let's Encrypt for (None: switched off, or no domain name).
+ pub fn acme_order(&self) -> Option {
+ let domains = crate::acme::domains(&self.acme_domains);
+ (self.https && self.acme && !domains.is_empty()).then(|| crate::acme::Order {
+ domains,
+ email: self.acme_email.trim().to_string(),
+ directory: if !self.acme_directory.trim().is_empty() {
+ self.acme_directory.trim().to_string()
+ } else if self.acme_staging {
+ crate::acme::LETS_ENCRYPT_STAGING.into()
+ } else {
+ crate::acme::LETS_ENCRYPT.into()
+ },
+ })
+ }
}
/// What the apps are told changed, worked out off the playout lock.
@@ -161,15 +218,21 @@ struct Api {
/// token → (user, last used)
tokens: Mutex>,
failures: Mutex<(u32, Instant)>,
- /// what is running now (server.json may have changed since): (https, portal, portal port)
- running: (bool, bool, u16),
- /// of the certificate in use (empty without HTTPS)
- fingerprint: String,
- /// HTTPS with a certificate browsers trust: the portal tells them to use only HTTPS. (Not with a
- /// self-signed one, where HSTS would stop browsers from letting the user past their warning.)
- hsts: bool,
+ /// what is running now (server.json may have changed since): (https, portal, portal port, Let's Encrypt)
+ running: (bool, bool, u16, bool),
+ /// the certificate in use (None without HTTPS)
+ certs: Option>,
/// token → the mic audio of that app, while its mic is on
mics: Mutex>>>,
+ /// Let's Encrypt's checks being answered, and how getting the certificate went
+ challenges: crate::acme::Challenges,
+ acme: crate::acme::Shared,
+ /// the Cloudflare Tunnel while it is on, and the address it points at
+ tunnel: Mutex