[feat](trx-client): redesign scheduler and background decode UX

Visual 24h timeline bar, inline entry editor, interleave progress ring, filterable checkbox list for bookmarks, status cards moved to top, SVG dot state badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-29 22:55:12 +02:00
parent c041ac83f3
commit 41a53b3376
4 changed files with 433 additions and 132 deletions
@@ -344,7 +344,17 @@
<button id="scheduler-next-btn" type="button">Next Entry</button> <button id="scheduler-next-btn" type="button">Next Entry</button>
</div> </div>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div> <div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
<div id="scheduler-cycle-status" class="scheduler-cycle-status">Interleaving: --</div> <div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -986,6 +996,10 @@
<div id="subtab-settings-scheduler" class="sub-tab-panel"> <div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel"> <div id="scheduler-panel" class="sch-panel">
<div class="sch-toast" id="scheduler-toast" style="display:none;"></div> <div class="sch-toast" id="scheduler-toast" style="display:none;"></div>
<!-- Now Playing status card (moved to top) -->
<div class="now-playing-card">
<div id="scheduler-status-card" class="sch-status-card">No activity yet.</div>
</div>
<div class="sch-row"> <div class="sch-row">
<label class="sch-label">Mode <label class="sch-label">Mode
<select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode"> <select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode">
@@ -1035,13 +1049,53 @@
</label> </label>
<small style="color:var(--text-muted);align-self:flex-end;padding-bottom:0.35rem;">When multiple entries overlap, spend this many minutes at each before cycling. Leave blank to disable.</small> <small style="color:var(--text-muted);align-self:flex-end;padding-bottom:0.35rem;">When multiple entries overlap, spend this many minutes at each before cycling. Leave blank to disable.</small>
</div> </div>
<div id="scheduler-ts-timeline" class="sch-timeline-wrap"></div>
<button id="scheduler-ts-add-btn" class="sch-write" type="button" style="margin-bottom:0.75rem;">+ Add Entry</button> <button id="scheduler-ts-add-btn" class="sch-write" type="button" style="margin-bottom:0.75rem;">+ Add Entry</button>
<table class="sch-ts-table"> <div id="sch-entry-form-wrap" style="display:none;">
<thead> <form id="sch-entry-form" class="bm-form">
<tr><th>Start</th><th>End</th><th>Center freq</th><th>Primary bookmark</th><th>Extra channels</th><th>Label</th><th>Interleave (min)</th><th></th></tr> <div class="bm-form-title" id="sch-entry-form-title">Add Entry</div>
</thead> <div class="bm-form-grid">
<tbody id="scheduler-ts-tbody"></tbody> <label class="bm-label">Start (UTC)
</table> <input type="time" id="scheduler-ts-start" class="status-input" title="Set both to 00:00 for all-day" />
</label>
<label class="bm-label">End (UTC)
<input type="time" id="scheduler-ts-end" class="status-input" title="Set both to 00:00 for all-day" />
</label>
<label class="bm-label" id="scheduler-ts-center-hz-wrap" title="SDR only — sets center frequency before tuning">Center freq (Hz, SDR)
<input type="number" id="scheduler-ts-center-hz" class="status-input" min="0" placeholder="optional" />
</label>
<label class="bm-label bm-label-wide">Primary bookmark
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
</label>
<label class="bm-label">Extra channels (virtual)
<div id="scheduler-ts-extra-bm-list" class="sch-extra-bm-list"></div>
<div style="display:flex;gap:0.4rem;margin-top:0.3rem;">
<select id="scheduler-ts-extra-bm-pick" class="status-input" aria-label="Extra bookmark"></select>
<button id="scheduler-ts-extra-bm-add" type="button" class="sch-write" style="padding:0 0.7rem;">+</button>
</div>
</label>
<label class="bm-label">Label (optional)
<input type="text" id="scheduler-ts-label" class="status-input" placeholder="e.g. 40m FT8" />
</label>
<label class="bm-label">Interleave (min, optional)
<input type="number" id="scheduler-ts-entry-interleave" class="status-input" min="1" max="60" placeholder="default" />
</label>
</div>
<div class="bm-form-actions">
<button type="submit" class="bm-save-btn">Save</button>
<button type="button" id="sch-entry-form-cancel">Cancel</button>
</div>
</form>
</div>
<details class="sch-ts-details">
<summary>Entry details</summary>
<table class="sch-ts-table">
<thead>
<tr><th>Start</th><th>End</th><th>Center freq</th><th>Primary bookmark</th><th>Extra channels</th><th>Label</th><th>Interleave (min)</th><th></th></tr>
</thead>
<tbody id="scheduler-ts-tbody"></tbody>
</table>
</details>
</div> </div>
<!-- Satellite Overlay section --> <!-- Satellite Overlay section -->
@@ -1111,59 +1165,21 @@
</form> </form>
</div> </div>
<div id="sch-entry-form-wrap" style="display:none;">
<form id="sch-entry-form" class="bm-form">
<div class="bm-form-title" id="sch-entry-form-title">Add Entry</div>
<div class="bm-form-grid">
<label class="bm-label">Start (UTC)
<input type="time" id="scheduler-ts-start" class="status-input" title="Set both to 00:00 for all-day" />
</label>
<label class="bm-label">End (UTC)
<input type="time" id="scheduler-ts-end" class="status-input" title="Set both to 00:00 for all-day" />
</label>
<label class="bm-label" id="scheduler-ts-center-hz-wrap" title="SDR only — sets center frequency before tuning">Center freq (Hz, SDR)
<input type="number" id="scheduler-ts-center-hz" class="status-input" min="0" placeholder="optional" />
</label>
<label class="bm-label bm-label-wide">Primary bookmark
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
</label>
<label class="bm-label">Extra channels (virtual)
<div id="scheduler-ts-extra-bm-list" class="sch-extra-bm-list"></div>
<div style="display:flex;gap:0.4rem;margin-top:0.3rem;">
<select id="scheduler-ts-extra-bm-pick" class="status-input" aria-label="Extra bookmark"></select>
<button id="scheduler-ts-extra-bm-add" type="button" class="sch-write" style="padding:0 0.7rem;">+</button>
</div>
</label>
<label class="bm-label">Label (optional)
<input type="text" id="scheduler-ts-label" class="status-input" placeholder="e.g. 40m FT8" />
</label>
<label class="bm-label">Interleave (min, optional)
<input type="number" id="scheduler-ts-entry-interleave" class="status-input" min="1" max="60" placeholder="default" />
</label>
</div>
<div class="bm-form-actions">
<button type="submit" class="bm-save-btn">Save</button>
<button type="button" id="sch-entry-form-cancel">Cancel</button>
</div>
</form>
</div>
<!-- Actions --> <!-- Actions -->
<div class="sch-actions"> <div class="sch-actions">
<button id="scheduler-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button> <button id="scheduler-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button>
<button id="scheduler-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset to Disabled</button> <button id="scheduler-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset to Disabled</button>
</div> </div>
<!-- Status -->
<div class="sch-section">
<div class="sch-section-title">Last Activity</div>
<div id="scheduler-status-card" class="sch-status-card">No activity yet.</div>
</div>
</div> </div>
</div> </div>
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;"> <div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
<div id="background-decode-panel" class="sch-panel"> <div id="background-decode-panel" class="sch-panel">
<div class="sch-toast" id="background-decode-toast" style="display:none;"></div> <div class="sch-toast" id="background-decode-toast" style="display:none;"></div>
<!-- Now Playing status card (moved to top) -->
<div class="now-playing-card">
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
</div>
<div class="sch-section"> <div class="sch-section">
<div class="sch-section-title">Configuration</div> <div class="sch-section-title">Configuration</div>
<div class="sch-row"> <div class="sch-row">
@@ -1174,26 +1190,18 @@
</span> </span>
</label> </label>
</div> </div>
<div class="sch-row"> <div class="sch-row" style="flex-direction:column;gap:0.5rem;">
<label class="sch-label bgd-bookmark-pick">Bookmark <label class="sch-label" style="min-width:100%;">Bookmarks
<div class="bgd-add-row"> <input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
<select id="background-decode-bookmark-pick" class="status-input" aria-label="Background decode bookmark"></select>
<button id="background-decode-bookmark-add" type="button" class="sch-write">+ Add</button>
</div>
</label> </label>
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
</div> </div>
<div id="background-decode-bookmark-list" class="sch-extra-bm-list bgd-bookmark-list"></div>
</div> </div>
<div class="sch-actions"> <div class="sch-actions">
<button id="background-decode-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button> <button id="background-decode-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button>
<button id="background-decode-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset</button> <button id="background-decode-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset</button>
</div> </div>
<div class="sch-section">
<div class="sch-section-title">Runtime Status</div>
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
</div>
</div> </div>
</div> </div>
<div id="subtab-settings-history" class="sub-tab-panel" style="display:none;"> <div id="subtab-settings-history" class="sub-tab-panel" style="display:none;">
@@ -76,7 +76,6 @@
.then(function ([config, bookmarks]) { .then(function ([config, bookmarks]) {
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] }; currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
bookmarkList = Array.isArray(bookmarks) ? bookmarks : []; bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
renderBookmarkPick();
renderBackgroundDecode(); renderBackgroundDecode();
pollBackgroundDecodeStatus(); pollBackgroundDecodeStatus();
}) })
@@ -105,26 +104,12 @@
return supported; return supported;
} }
function renderBookmarkPick() {
const sel = document.getElementById("background-decode-bookmark-pick");
if (!sel) return;
const selectedIds = new Set(currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []);
sel.innerHTML = '<option value="">- select bookmark -</option>';
supportedBookmarks().forEach(function (bookmark) {
if (selectedIds.has(bookmark.id)) return;
const opt = document.createElement("option");
opt.value = bookmark.id;
opt.textContent = bookmark.name + " (" + formatFreq(bookmark.freq_hz) + " " + bookmark.mode + ")";
sel.appendChild(opt);
});
}
function renderBackgroundDecode() { function renderBackgroundDecode() {
if (!currentConfig) { if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
} }
setCheckbox("background-decode-enabled", !!currentConfig.enabled); setCheckbox("background-decode-enabled", !!currentConfig.enabled);
renderBookmarkList(); renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled); const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
const panel = document.getElementById("background-decode-panel"); const panel = document.getElementById("background-decode-panel");
@@ -139,52 +124,57 @@
if (resetBtn) resetBtn.style.display = isControl ? "" : "none"; if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
} }
function renderBookmarkList() { function renderBookmarkChecklist(filterText) {
const container = document.getElementById("background-decode-bookmark-list"); const container = document.getElementById("bgd-bookmark-checklist");
if (!container) return; if (!container) return;
container.innerHTML = ""; container.innerHTML = "";
const ids = currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : [];
if (!ids.length) { const selectedIds = new Set(
container.textContent = "No background decode bookmarks selected."; currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
);
const all = supportedBookmarks();
const filter = (filterText || "").trim().toLowerCase();
const filtered = filter
? all.filter(function (bm) {
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
return text.indexOf(filter) >= 0;
})
: all;
if (filtered.length === 0) {
container.innerHTML = '<div class="bgd-checklist-empty">' +
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
'</div>';
return; return;
} }
ids.forEach(function (id) {
const bookmark = bookmarkList.find(function (item) { return item.id === id; }); filtered.forEach(function (bookmark) {
const chip = document.createElement("div"); var row = document.createElement("label");
chip.className = "sch-extra-bm-tag bgd-bookmark-tag"; row.className = "bgd-checklist-row";
const decoders = bookmarkDecoderKinds(bookmark); var decoders = bookmarkDecoderKinds(bookmark);
chip.innerHTML = var checked = selectedIds.has(bookmark.id) ? " checked" : "";
'<span>' + escHtml(bookmark ? bookmark.name : id) + '</span>' + row.innerHTML =
'<span class="bgd-bookmark-meta">' + escHtml(bookmark ? (formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) : "Missing bookmark") + '</span>'; '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
const btn = document.createElement("span"); '<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
btn.className = "sch-extra-bm-rm"; '<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
btn.textContent = "×"; row.querySelector("input").addEventListener("change", function (e) {
btn.addEventListener("click", function () { onChecklistToggle(bookmark.id, e.target.checked);
removeBookmark(id);
}); });
chip.appendChild(btn); container.appendChild(row);
container.appendChild(chip);
}); });
} }
function removeBookmark(id) { function onChecklistToggle(bookmarkId, checked) {
if (!currentConfig || !Array.isArray(currentConfig.bookmark_ids)) return;
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (item) { return item !== id; });
renderBookmarkPick();
renderBackgroundDecode();
}
function addBookmark() {
const sel = document.getElementById("background-decode-bookmark-pick");
if (!sel || !sel.value) return;
if (!currentConfig) { if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
} }
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = []; if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
if (!currentConfig.bookmark_ids.includes(sel.value)) currentConfig.bookmark_ids.push(sel.value); if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
sel.value = ""; currentConfig.bookmark_ids.push(bookmarkId);
renderBookmarkPick(); } else if (!checked) {
renderBackgroundDecode(); currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (id) { return id !== bookmarkId; });
}
} }
function saveBackgroundDecode() { function saveBackgroundDecode() {
@@ -200,7 +190,6 @@
apiPutConfig(rigId, payload) apiPutConfig(rigId, payload)
.then(function (saved) { .then(function (saved) {
currentConfig = saved; currentConfig = saved;
renderBookmarkPick();
renderBackgroundDecode(); renderBackgroundDecode();
pollBackgroundDecodeStatus(); pollBackgroundDecodeStatus();
showToast("Background decode saved."); showToast("Background decode saved.");
@@ -219,7 +208,6 @@
apiResetConfig(rigId) apiResetConfig(rigId)
.then(function (saved) { .then(function (saved) {
currentConfig = saved; currentConfig = saved;
renderBookmarkPick();
renderBackgroundDecode(); renderBackgroundDecode();
pollBackgroundDecodeStatus(); pollBackgroundDecodeStatus();
showToast("Background decode reset."); showToast("Background decode reset.");
@@ -273,7 +261,9 @@
'<div class="bgd-status-name">' + escHtml(name) + '</div>' + '<div class="bgd-status-name">' + escHtml(name) + '</div>' +
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' + '<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
'</div>' + '</div>' +
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' + escHtml(prettyState(entry.state)) + '</div>' + '<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
escHtml(prettyState(entry.state)) + '</div>' +
'</div>'; '</div>';
}); });
html += "</div>"; html += "</div>";
@@ -328,10 +318,12 @@
} }
function wireBackgroundDecodeEvents() { function wireBackgroundDecodeEvents() {
const addBtn = document.getElementById("background-decode-bookmark-add"); const filterInput = document.getElementById("bgd-bookmark-filter");
if (addBtn && !addBtn._wired) { if (filterInput && !filterInput._wired) {
addBtn._wired = true; filterInput._wired = true;
addBtn.addEventListener("click", addBookmark); filterInput.addEventListener("input", function () {
renderBookmarkChecklist(filterInput.value);
});
} }
const saveBtn = document.getElementById("background-decode-save-btn"); const saveBtn = document.getElementById("background-decode-save-btn");
@@ -275,17 +275,36 @@
}; };
} }
function schedulerInterleaveSummary(config) {
const state = schedulerInterleaveState(config);
if (state.activeEntries.length <= 1 || !(state.cycleMin > 0)) return "Interleaving: off";
const activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]);
return "Interleaving: " + activeName + " · next switch in " + state.remainingSec + "s (" + state.cycleMin + " min cycle)";
}
function renderSchedulerInterleaveStatus() { function renderSchedulerInterleaveStatus() {
const el = document.getElementById("scheduler-cycle-status"); const wrap = document.getElementById("scheduler-cycle-status");
if (!el) return; if (!wrap) return;
el.textContent = schedulerInterleaveSummary(currentConfig);
const state = schedulerInterleaveState(currentConfig);
const isActive = state.activeEntries.length > 1 && state.cycleMin > 0;
wrap.style.display = isActive ? "" : "none";
if (isActive) {
var activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]);
var totalSlotSec = state.cycleMin > 0
? (state.cycleMin * 60) / state.activeEntries.length
: 0;
var elapsedPct = totalSlotSec > 0
? Math.min(100, Math.max(0, ((totalSlotSec - state.remainingSec) / totalSlotSec) * 100))
: 0;
var ringFill = document.getElementById("interleave-ring-fill");
if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct));
var nameEl = document.getElementById("interleave-active-name");
if (nameEl) nameEl.textContent = activeName;
var countdownEl = document.getElementById("interleave-countdown");
if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s · " + state.cycleMin + "m cycle";
}
// Also update the timeline needle if visible
renderTimelineNeedle();
renderSchedulerStepControls(); renderSchedulerStepControls();
} }
@@ -459,7 +478,7 @@
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Entry form (modal — mirrors bookmark add/edit modal) // Entry form (inline card below Add Entry button)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function schOpenEntryForm(entry, idx) { function schOpenEntryForm(entry, idx) {
schEntryEditIdx = (idx != null) ? idx : null; schEntryEditIdx = (idx != null) ? idx : null;
@@ -486,7 +505,7 @@
const wrap = document.getElementById("sch-entry-form-wrap"); const wrap = document.getElementById("sch-entry-form-wrap");
if (wrap) { if (wrap) {
wrap.style.display = "flex"; wrap.style.display = "block";
if (startEl) startEl.focus(); if (startEl) startEl.focus();
} }
} }
@@ -552,6 +571,100 @@
renderTimespanEntries(); renderTimespanEntries();
} }
// -------------------------------------------------------------------------
// 24h Timeline Bar
// -------------------------------------------------------------------------
var TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"];
function renderTimeline() {
var container = document.getElementById("scheduler-ts-timeline");
if (!container) return;
var entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : [];
if (entries.length === 0) {
container.innerHTML = "";
return;
}
var W = 1000;
var H = 62;
var BAR_Y = 6;
var BAR_H = 30;
var TICK_Y = BAR_Y + BAR_H + 2;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none">';
// Background bar
svg += '<rect x="0" y="' + BAR_Y + '" width="' + W + '" height="' + BAR_H + '" rx="3" fill="var(--btn-bg)" />';
// Entry segments
entries.forEach(function (entry, idx) {
var start = Number(entry.start_min);
var end = Number(entry.end_min);
if (!Number.isFinite(start) || !Number.isFinite(end)) return;
var color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length];
if (start === end) {
// All-day entry
svg += '<rect class="sch-timeline-seg" x="0" y="' + BAR_Y + '" width="' + W + '" height="' + BAR_H +
'" rx="3" fill="' + color + '" data-idx="' + idx + '" />';
} else if (start < end) {
var x = (start / 1440) * W;
var w = ((end - start) / 1440) * W;
svg += '<rect class="sch-timeline-seg" x="' + x.toFixed(1) + '" y="' + BAR_Y + '" width="' + w.toFixed(1) +
'" height="' + BAR_H + '" fill="' + color + '" data-idx="' + idx + '" />';
} else {
// Wrap-around: two segments
var x1 = (start / 1440) * W;
var w1 = W - x1;
svg += '<rect class="sch-timeline-seg" x="' + x1.toFixed(1) + '" y="' + BAR_Y + '" width="' + w1.toFixed(1) +
'" height="' + BAR_H + '" fill="' + color + '" data-idx="' + idx + '" />';
var w2 = (end / 1440) * W;
svg += '<rect class="sch-timeline-seg" x="0" y="' + BAR_Y + '" width="' + w2.toFixed(1) +
'" height="' + BAR_H + '" fill="' + color + '" data-idx="' + idx + '" />';
}
});
// Tick marks every 3 hours
for (var h = 0; h <= 24; h += 3) {
var tx = (h / 24) * W;
svg += '<line x1="' + tx.toFixed(1) + '" y1="' + TICK_Y + '" x2="' + tx.toFixed(1) + '" y2="' + (TICK_Y + 5) +
'" stroke="var(--border-light)" stroke-width="1" />';
if (h < 24) {
svg += '<text class="sch-timeline-tick-label" x="' + (tx + 3).toFixed(1) + '" y="' + (TICK_Y + 16) +
'">' + String(h).padStart(2, "0") + '</text>';
}
}
// Current time needle
svg += '<g id="sch-timeline-needle-g">' + timelineNeedleSvg() + '</g>';
svg += '</svg>';
container.innerHTML = svg;
// Wire click events on segments
container.querySelectorAll(".sch-timeline-seg").forEach(function (seg) {
seg.addEventListener("click", function () {
var i = parseInt(seg.getAttribute("data-idx"), 10);
var entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null;
if (entry) schOpenEntryForm(entry, i);
});
});
}
function timelineNeedleSvg() {
var info = schedulerUtcMinuteInfo();
var nowMin = info.minuteOfDay + (info.secondOfMinute / 60);
var x = (nowMin / 1440) * 1000;
return '<line class="sch-timeline-needle" x1="' + x.toFixed(1) + '" y1="2" x2="' + x.toFixed(1) + '" y2="38" />' +
'<polygon class="sch-timeline-needle-head" points="' +
(x - 3).toFixed(1) + ',2 ' + (x + 3).toFixed(1) + ',2 ' + x.toFixed(1) + ',6" />';
}
function renderTimelineNeedle() {
var g = document.getElementById("sch-timeline-needle-g");
if (g) g.innerHTML = timelineNeedleSvg();
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// TimeSpan entries table // TimeSpan entries table
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -598,6 +711,7 @@
removeEntry(parseInt(btn.dataset.idx, 10)); removeEntry(parseInt(btn.dataset.idx, 10));
}); });
}); });
renderTimeline();
} }
function bmName(id) { function bmName(id) {
@@ -3814,8 +3814,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
#sch-entry-form .bm-label { #sch-entry-form .bm-label {
gap: 0.35rem; gap: 0.35rem;
} }
#bm-form-wrap, #bm-form-wrap {
#sch-entry-form-wrap {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 120; z-index: 120;
@@ -4539,6 +4538,187 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.bgd-status-state[data-state="no_supported_decoders"] { .bgd-status-state[data-state="no_supported_decoders"] {
color: var(--accent-red); color: var(--accent-red);
} }
/* ── "Now Playing" status card (top of scheduler & bgd panels) ──── */
.now-playing-card {
border-left: 3px solid var(--accent-green);
padding: 0.75rem 1rem;
margin-bottom: 1.25rem;
background: var(--bg-secondary);
border-radius: 0 0.5rem 0.5rem 0;
font-size: 0.9rem;
}
/* ── 24h Timeline Bar ─────────────────────────────────────────────── */
.sch-timeline-wrap {
margin-bottom: 1rem;
overflow: hidden;
border-radius: 0.4rem;
border: 1px solid var(--border-light);
background: var(--bg-secondary);
}
.sch-timeline-wrap svg {
display: block;
width: 100%;
height: 62px;
}
.sch-timeline-seg {
cursor: pointer;
opacity: 0.82;
transition: opacity 0.12s;
}
.sch-timeline-seg:hover {
opacity: 1;
}
.sch-timeline-tick-label {
fill: var(--text-muted);
font-size: 9px;
font-family: inherit;
}
.sch-timeline-needle {
stroke: var(--accent-red);
stroke-width: 1.5;
}
.sch-timeline-needle-head {
fill: var(--accent-red);
}
/* Details toggle for entry table */
.sch-ts-details {
margin-top: 0.5rem;
}
.sch-ts-details summary {
cursor: pointer;
font-size: 0.82rem;
color: var(--text-muted);
font-weight: 600;
padding: 0.3rem 0;
user-select: none;
}
/* ── Inline Entry Editor (replaces modal overlay) ─────────────────── */
#sch-entry-form-wrap {
position: static;
z-index: auto;
display: none;
align-items: stretch;
justify-content: stretch;
background: none;
margin-bottom: 1rem;
}
#sch-entry-form-wrap .bm-form {
max-width: 100%;
width: 100%;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
background: var(--bg-secondary);
}
/* ── Interleave Progress Ring ─────────────────────────────────────── */
.interleave-ring-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
}
.interleave-ring {
width: 32px;
height: 32px;
transform: rotate(-90deg);
flex-shrink: 0;
}
.interleave-ring-bg {
fill: none;
stroke: var(--border-light);
stroke-width: 3;
}
.interleave-ring-fill {
fill: none;
stroke: var(--accent-green);
stroke-width: 3;
stroke-linecap: round;
transition: stroke-dashoffset 0.9s linear;
}
.interleave-ring-text {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.interleave-ring-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14rem;
}
.interleave-ring-sub {
font-size: 0.7rem;
color: var(--text-muted);
opacity: 0.88;
}
/* ── Background Decode Checkbox List ──────────────────────────────── */
.bgd-checklist-filter {
width: 100%;
margin-bottom: 0.5rem;
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: var(--radius, 4px);
color: var(--text);
}
.bgd-checklist-filter::placeholder {
color: var(--text-muted);
opacity: 0.7;
}
.bgd-checklist {
max-height: 16rem;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: 0.4rem;
background: var(--bg-secondary);
}
.bgd-checklist-row {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.65rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
font-size: 0.85rem;
transition: background 0.1s;
}
.bgd-checklist-row:last-child {
border-bottom: none;
}
.bgd-checklist-row:hover {
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
}
.bgd-checklist-row input[type="checkbox"] {
flex-shrink: 0;
}
.bgd-checklist-name {
font-weight: 600;
color: var(--text);
}
.bgd-checklist-meta {
color: var(--text-muted);
font-size: 0.78rem;
margin-left: auto;
white-space: nowrap;
}
.bgd-checklist-empty {
padding: 0.75rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
/* ── SVG State Dot Badges ─────────────────────────────────────────── */
.bgd-state-dot {
width: 8px;
height: 8px;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
fill: currentColor;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.channel-scheduler-controls { .channel-scheduler-controls {
flex-direction: column; flex-direction: column;
@@ -4587,6 +4767,13 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.bgd-add-row button { .bgd-add-row button {
width: 100%; width: 100%;
} }
.interleave-ring-label {
max-width: 8rem;
}
.bgd-checklist-meta {
margin-left: 0;
font-size: 0.72rem;
}
} }
/* ── SAT panel ──────────────────────────────────────────────────────── */ /* ── SAT panel ──────────────────────────────────────────────────────── */
.sat-view-bar { display: flex; gap: 0; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); } .sat-view-bar { display: flex; gap: 0; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); }