2026-09-09 15:42:53 -04:00
|
|
|
|
/* PCO Plan — client part.
|
|
|
|
|
|
|
2026-09-27 11:18:25 -04:00
|
|
|
|
The module presents three tiles and this is the entry for all of them: the
|
|
|
|
|
|
Teams tile (picker entry "teams") is built by teams-client.js and the Song
|
|
|
|
|
|
Key tile ("key") by key-client.js; everything below draws the Plan tile.
|
2026-09-24 12:42:57 -04:00
|
|
|
|
|
2026-09-09 15:42:53 -04:00
|
|
|
|
The server part reads Planning Center and (optionally) follows the
|
|
|
|
|
|
ProPresenter module's live item; this tile only renders the plan it is
|
2026-09-10 16:20:46 -04:00
|
|
|
|
streamed. What is shown is this tile's own choice (gear menu →
|
|
|
|
|
|
moduleApi.instanceSettings: show/hide checklist, top-bar template,
|
2026-09-14 15:35:47 -04:00
|
|
|
|
colors, item-note categories); the text size is the title bar's A− / A+
|
|
|
|
|
|
(saved with the tile, 14 px until someone presses one); /admin only holds
|
|
|
|
|
|
the connection settings. */
|
2026-09-09 15:42:53 -04:00
|
|
|
|
|
2026-09-24 12:42:57 -04:00
|
|
|
|
import createTeams from "./teams-client.js";
|
2026-09-27 11:18:25 -04:00
|
|
|
|
import createKey from "./key-client.js";
|
2026-10-06 14:32:19 -04:00
|
|
|
|
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
|
2026-09-27 11:27:31 -04:00
|
|
|
|
import { prettyKey } from "./key-notation.js";
|
2026-09-24 12:42:57 -04:00
|
|
|
|
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
|
|
|
|
|
|
2026-09-15 10:49:58 -04:00
|
|
|
|
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
|
|
|
|
|
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
|
|
|
|
|
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
|
|
|
|
|
|
2026-09-09 15:42:53 -04:00
|
|
|
|
export default function create({ root, moduleApi }) {
|
2026-09-27 11:18:25 -04:00
|
|
|
|
// Each picker entry carries its variant and its own settings.
|
2026-09-24 12:42:57 -04:00
|
|
|
|
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
2026-09-27 11:18:25 -04:00
|
|
|
|
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
|
2026-09-24 12:42:57 -04:00
|
|
|
|
return createPlan({ root, moduleApi });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createPlan({ root, moduleApi }) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
let state = null;
|
|
|
|
|
|
let skew = 0; // server clock − local clock, ms
|
|
|
|
|
|
let feedOffline = false;
|
|
|
|
|
|
let stream = null;
|
|
|
|
|
|
let ticker = null;
|
|
|
|
|
|
let lastCurrentId = null;
|
|
|
|
|
|
let lastListKey = '';
|
2026-09-27 08:31:26 -04:00
|
|
|
|
let absView = null; // what the server says AbleSet is doing, if anything
|
2026-10-06 14:32:19 -04:00
|
|
|
|
// The plan this dashboard is looking at — usually the one the server
|
|
|
|
|
|
// follows, otherwise one of the others it also reads.
|
|
|
|
|
|
let view = viewOf(null);
|
|
|
|
|
|
let offViewed = null;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
let userScrolledAt = 0;
|
|
|
|
|
|
|
|
|
|
|
|
root.innerHTML = `
|
|
|
|
|
|
<div class="pp-wrap">
|
|
|
|
|
|
<header class="pp-head">
|
|
|
|
|
|
<div class="pp-plan">
|
|
|
|
|
|
<div class="pp-plan-title"></div>
|
|
|
|
|
|
<div class="pp-plan-sub"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="pp-clock">
|
|
|
|
|
|
<div class="pp-clock-label"></div>
|
|
|
|
|
|
<div class="pp-clock-value"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<div class="pp-list"></div>
|
|
|
|
|
|
<footer class="pp-foot" hidden></footer>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
const wrap = root.querySelector('.pp-wrap');
|
|
|
|
|
|
const headEl = root.querySelector('.pp-head');
|
|
|
|
|
|
const planTitleEl = root.querySelector('.pp-plan-title');
|
|
|
|
|
|
const planSubEl = root.querySelector('.pp-plan-sub');
|
|
|
|
|
|
const clockLabelEl = root.querySelector('.pp-clock-label');
|
|
|
|
|
|
const clockValueEl = root.querySelector('.pp-clock-value');
|
|
|
|
|
|
const listEl = root.querySelector('.pp-list');
|
|
|
|
|
|
const footEl = root.querySelector('.pp-foot');
|
|
|
|
|
|
|
2026-09-10 16:20:46 -04:00
|
|
|
|
/** This tile's display settings (gear menu) — read fresh on every render. */
|
|
|
|
|
|
const cfg = () => moduleApi.instanceSettings || {};
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const on = (key) => cfg()[key] !== false; // unset = shown
|
2026-09-10 16:20:46 -04:00
|
|
|
|
const tilePrefs = cfg;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const serverNow = () => Date.now() + skew;
|
|
|
|
|
|
|
|
|
|
|
|
/* ── title-bar controls ─────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
const refreshBtn = moduleApi.header.addButton({
|
|
|
|
|
|
icon: REFRESH_SVG,
|
2026-09-15 10:49:58 -04:00
|
|
|
|
title: 'Refresh',
|
2026-09-09 15:42:53 -04:00
|
|
|
|
onClick() {
|
|
|
|
|
|
refreshBtn.classList.add('active');
|
|
|
|
|
|
moduleApi.fetch('/refresh', { method: 'POST' })
|
|
|
|
|
|
.catch(() => { /* the stream reports the outcome */ })
|
|
|
|
|
|
.finally(() => refreshBtn.classList.remove('active'));
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function bumpTextSize(delta) {
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const size = Number(tilePrefs().textSize) || 14;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
|
|
|
|
|
applyPrefs();
|
|
|
|
|
|
}
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
|
2026-09-15 10:49:58 -04:00
|
|
|
|
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
|
|
|
|
|
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
|
2026-09-09 15:42:53 -04:00
|
|
|
|
|
|
|
|
|
|
/* ── formatting ─────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function fmtClock(ms) {
|
|
|
|
|
|
if (!ms) return '';
|
|
|
|
|
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fmtDur(totalSec, { signed = false } = {}) {
|
|
|
|
|
|
const sign = totalSec < 0 ? '-' : (signed ? '+' : '');
|
|
|
|
|
|
const s = Math.abs(Math.round(totalSec));
|
|
|
|
|
|
const h = Math.floor(s / 3600);
|
|
|
|
|
|
const m = Math.floor((s % 3600) / 60);
|
|
|
|
|
|
const sec = s % 60;
|
|
|
|
|
|
if (h) return `${sign}${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
|
|
|
|
|
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 09:58:47 -04:00
|
|
|
|
/** "Starts in": under a day like fmtDur; a day or more reads "2 Days, 05:12:44". */
|
|
|
|
|
|
function fmtStartsIn(totalSec) {
|
|
|
|
|
|
const s = Math.max(0, Math.round(totalSec));
|
|
|
|
|
|
const days = Math.floor(s / 86400);
|
|
|
|
|
|
if (!days) return fmtDur(s);
|
|
|
|
|
|
const rest = s % 86400;
|
|
|
|
|
|
const pad = (v) => String(v).padStart(2, '0');
|
|
|
|
|
|
return `${days} ${days === 1 ? 'Day' : 'Days'}, ${pad(Math.floor(rest / 3600))}:${pad(Math.floor((rest % 3600) / 60))}:${pad(rest % 60)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-09 15:42:53 -04:00
|
|
|
|
function fmtMinutes(totalSec) {
|
|
|
|
|
|
const m = Math.round(totalSec / 60);
|
|
|
|
|
|
if (m < 60) return `${m} min`;
|
|
|
|
|
|
const h = Math.floor(m / 60);
|
|
|
|
|
|
const rest = m % 60;
|
|
|
|
|
|
return rest ? `${h} h ${rest} min` : `${h} h`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function typeLabel(itemType) {
|
|
|
|
|
|
switch (itemType) {
|
|
|
|
|
|
case 'song': return 'Song';
|
|
|
|
|
|
case 'media': return 'Media';
|
|
|
|
|
|
default: return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 15:31:12 -04:00
|
|
|
|
function plannedLength() {
|
|
|
|
|
|
return (state?.items || [])
|
|
|
|
|
|
.filter((it) => it.servicePosition === 'during')
|
|
|
|
|
|
.reduce((sum, it) => sum + (it.length || 0), 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-09 15:42:53 -04:00
|
|
|
|
/* ── which items to show ────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function visibleItems() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const items = Array.isArray(view.items) ? view.items : [];
|
2026-09-09 15:42:53 -04:00
|
|
|
|
return items.filter((it) => {
|
2026-09-10 15:31:12 -04:00
|
|
|
|
if (it.itemType === 'header' && !on('showHeaders')) return false;
|
|
|
|
|
|
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
|
|
|
|
|
|
if (it.servicePosition === 'post' && !on('showPostService')) return false;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 15:35:47 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Which note categories this tile shows. Unset (never chosen in the gear
|
|
|
|
|
|
* window) means every category, including ones Planning Center gains
|
|
|
|
|
|
* later; an array is exactly those, by name. A layout saved before the
|
|
|
|
|
|
* categories were a list holds a comma-separated string — read as that
|
|
|
|
|
|
* list, blank as unset.
|
|
|
|
|
|
*/
|
2026-09-09 15:42:53 -04:00
|
|
|
|
function noteFilter() {
|
2026-09-14 15:35:47 -04:00
|
|
|
|
const raw = cfg().noteCategories;
|
|
|
|
|
|
const names = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
|
|
|
|
|
|
const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
|
|
|
|
|
|
if (!Array.isArray(raw) && !wanted.size) return () => true;
|
|
|
|
|
|
return (note) => wanted.has(String(note.category || '').trim().toLowerCase());
|
2026-09-09 15:42:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── status dot ─────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function renderStatus() {
|
|
|
|
|
|
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
|
|
|
|
|
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
|
|
|
|
|
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
2026-09-10 15:31:12 -04:00
|
|
|
|
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
2026-09-09 15:42:53 -04:00
|
|
|
|
if (!state.reachable) {
|
|
|
|
|
|
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
|
|
|
|
|
}
|
|
|
|
|
|
const live = state.live || {};
|
|
|
|
|
|
let extra = '';
|
|
|
|
|
|
if (live.following) {
|
|
|
|
|
|
extra = live.available
|
|
|
|
|
|
? (live.ppReachable ? ' · following ProPresenter' : ' · ProPresenter unreachable')
|
|
|
|
|
|
: ` · ${live.reason || 'ProPresenter feed unavailable'}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 15:31:12 -04:00
|
|
|
|
/* ── top bar: admin template with placeholders ──────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_TEMPLATE = '{series} • {part} | {serviceType} • {date}';
|
|
|
|
|
|
|
|
|
|
|
|
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
|
|
|
|
|
|
function templateValues() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const plan = view.plan || {};
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const items = state?.items || [];
|
|
|
|
|
|
const planned = plannedLength();
|
|
|
|
|
|
return {
|
|
|
|
|
|
series: on('showSeries') ? plan.seriesTitle || '' : '',
|
|
|
|
|
|
part: on('showPart') ? plan.title || '' : '',
|
|
|
|
|
|
title: on('showPart') ? plan.title || '' : '',
|
2026-10-06 14:32:19 -04:00
|
|
|
|
servicetype: on('showServiceType') ? view.name || '' : '',
|
2026-09-10 15:31:12 -04:00
|
|
|
|
date: on('showDate') ? plan.dates || '' : '',
|
|
|
|
|
|
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
|
|
|
|
|
|
time: on('showServiceTime') && plan.serviceStartsAt
|
|
|
|
|
|
? `${plan.serviceName && plan.serviceName !== 'Service' ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`
|
|
|
|
|
|
: '',
|
|
|
|
|
|
length: on('showTotalLength') && planned ? fmtMinutes(planned) : '',
|
|
|
|
|
|
items: items.filter((it) => it.itemType !== 'header').length ? String(items.filter((it) => it.itemType !== 'header').length) : '',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* "{series} • {part} | {serviceType} • {date}" → up to two lines. Within a
|
|
|
|
|
|
* line, • (or ·) separates segments; a segment whose placeholders all came
|
|
|
|
|
|
* out empty is dropped, so a plan with no series just reads "Wk 2".
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderTemplate(template, values) {
|
|
|
|
|
|
const lines = String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2);
|
|
|
|
|
|
return lines.map((line) => {
|
|
|
|
|
|
const segments = line.split(/\s*[•·]\s*/);
|
|
|
|
|
|
const kept = [];
|
|
|
|
|
|
for (const seg of segments) {
|
|
|
|
|
|
let sawVar = false;
|
|
|
|
|
|
let allEmpty = true;
|
|
|
|
|
|
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
|
|
|
|
|
|
sawVar = true;
|
|
|
|
|
|
const v = values[name.toLowerCase()];
|
|
|
|
|
|
if (v) allEmpty = false;
|
|
|
|
|
|
return v || '';
|
|
|
|
|
|
}).trim();
|
|
|
|
|
|
if (sawVar && allEmpty) continue;
|
|
|
|
|
|
if (text) kept.push(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
return kept.join(' • ');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-09 15:42:53 -04:00
|
|
|
|
|
|
|
|
|
|
function renderHead() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const plan = view.plan;
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const show = on('showTopBar') && plan;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
headEl.hidden = !show;
|
|
|
|
|
|
if (!show) return;
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
2026-10-06 14:32:19 -04:00
|
|
|
|
planTitleEl.textContent = line1 || line2 || view.name || 'Plan';
|
2026-09-10 15:31:12 -04:00
|
|
|
|
planSubEl.textContent = line1 ? line2 || '' : '';
|
|
|
|
|
|
planSubEl.hidden = !planSubEl.textContent;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
renderClock();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 12:58:12 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* The header clock follows the service the server says owns the clock
|
|
|
|
|
|
* (state.service: idle → running → stopped, by schedule or by the admin's
|
|
|
|
|
|
* ProPresenter document cues): a countdown before it, the running time
|
|
|
|
|
|
* while it runs (with ahead/behind when following ProPresenter), the final
|
|
|
|
|
|
* time once it has ended.
|
|
|
|
|
|
*/
|
2026-09-09 15:42:53 -04:00
|
|
|
|
function renderClock() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const plan = view.plan;
|
|
|
|
|
|
const live = view.live ? (state?.live || {}) : {};
|
|
|
|
|
|
// The service window belongs to the type being followed. Another type's
|
|
|
|
|
|
// plan must not borrow its clock — it would read as that service running.
|
|
|
|
|
|
// With none, the fall-through below shows that plan's own start time.
|
|
|
|
|
|
const svc = view.live ? (state?.service || {}) : {};
|
2026-09-09 15:42:53 -04:00
|
|
|
|
clockLabelEl.textContent = '';
|
|
|
|
|
|
clockValueEl.textContent = '';
|
|
|
|
|
|
clockValueEl.className = 'pp-clock-value';
|
|
|
|
|
|
if (!plan || headEl.hidden) return;
|
|
|
|
|
|
const now = serverNow();
|
2026-09-13 12:58:12 -04:00
|
|
|
|
const startedAt = svc.startedAt || live.serviceStartedAt || 0;
|
|
|
|
|
|
if (on('showRunningClock') && svc.phase === 'running' && startedAt) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
clockLabelEl.textContent = 'Running';
|
2026-09-13 12:58:12 -04:00
|
|
|
|
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
|
|
|
|
|
|
if (live.following) {
|
|
|
|
|
|
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const done = view.items.filter((it) => live.history?.[it.id]?.endedAt);
|
2026-09-13 12:58:12 -04:00
|
|
|
|
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
|
|
|
|
|
|
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
|
|
|
|
|
|
const drift = actual - planned;
|
|
|
|
|
|
if (planned && Math.abs(drift) >= 30) {
|
|
|
|
|
|
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
|
|
|
|
|
|
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
|
|
|
|
|
|
}
|
2026-09-09 15:42:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-13 12:58:12 -04:00
|
|
|
|
if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) {
|
|
|
|
|
|
clockLabelEl.textContent = 'Ended';
|
|
|
|
|
|
clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000);
|
2026-09-09 15:42:53 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-13 12:58:12 -04:00
|
|
|
|
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
|
|
|
|
|
|
if (on('showCountdown') && startsAt > now) {
|
|
|
|
|
|
clockLabelEl.textContent = 'Starts in';
|
2026-09-15 09:58:47 -04:00
|
|
|
|
clockValueEl.textContent = fmtStartsIn((startsAt - now) / 1000);
|
2026-09-13 12:58:12 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (on('showRunningClock') && startsAt) {
|
|
|
|
|
|
// Past the scheduled start and not started: waiting for the admin's
|
|
|
|
|
|
// start document to go live in ProPresenter (or its failsafe).
|
|
|
|
|
|
clockLabelEl.textContent = 'Scheduled';
|
|
|
|
|
|
clockValueEl.textContent = fmtClock(startsAt);
|
2026-09-09 15:42:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── the list ───────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function emptyMessage() {
|
|
|
|
|
|
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
|
|
|
|
|
|
if (!state) return { title: 'Connecting…', hint: '' };
|
|
|
|
|
|
if (!state.configured) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: 'Planning Center not connected',
|
2026-09-10 15:31:12 -04:00
|
|
|
|
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
|
2026-09-09 15:42:53 -04:00
|
|
|
|
link: { href: '/admin', text: 'Open Admin' },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.serviceTypeSet) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: 'No service type selected',
|
2026-09-10 15:31:12 -04:00
|
|
|
|
hint: 'Pick the service type to follow from the list under PCO Plan in Admin.',
|
|
|
|
|
|
link: { href: '/admin', text: 'Open Admin' },
|
2026-09-09 15:42:53 -04:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (!state.reachable && !view.plan) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
|
|
|
|
|
}
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (!view.plan) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
return {
|
|
|
|
|
|
title: 'No plan found',
|
2026-10-06 14:32:19 -04:00
|
|
|
|
hint: `${view.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
2026-09-09 15:42:53 -04:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-09-10 16:20:46 -04:00
|
|
|
|
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
|
2026-09-09 15:42:53 -04:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 15:31:12 -04:00
|
|
|
|
function buildRow(item, number, notesWanted) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const row = document.createElement('div');
|
|
|
|
|
|
row.className = 'pp-row';
|
|
|
|
|
|
row.dataset.id = item.id;
|
|
|
|
|
|
if (item.itemType === 'header') row.classList.add('is-header');
|
2026-09-10 15:31:12 -04:00
|
|
|
|
else row.classList.add(`is-${item.itemType === 'song' || item.itemType === 'media' ? item.itemType : 'item'}`);
|
2026-09-09 15:42:53 -04:00
|
|
|
|
if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const isHeader = item.itemType === 'header';
|
2026-09-09 15:42:53 -04:00
|
|
|
|
|
2026-09-10 15:31:12 -04:00
|
|
|
|
if (on('showItemNumbers')) {
|
|
|
|
|
|
const numCol = document.createElement('div');
|
|
|
|
|
|
numCol.className = 'pp-col-num';
|
|
|
|
|
|
numCol.textContent = isHeader ? '' : String(number);
|
|
|
|
|
|
row.appendChild(numCol);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (on('showStartTimes')) {
|
|
|
|
|
|
const timeCol = document.createElement('div');
|
|
|
|
|
|
timeCol.className = 'pp-col-time';
|
|
|
|
|
|
timeCol.textContent = item.startsAt && !isHeader ? fmtClock(item.startsAt) : '';
|
|
|
|
|
|
row.appendChild(timeCol);
|
|
|
|
|
|
}
|
2026-09-09 15:42:53 -04:00
|
|
|
|
|
|
|
|
|
|
const main = document.createElement('div');
|
|
|
|
|
|
main.className = 'pp-col-main';
|
|
|
|
|
|
const titleLine = document.createElement('div');
|
|
|
|
|
|
titleLine.className = 'pp-title-line';
|
|
|
|
|
|
const titleEl = document.createElement('span');
|
|
|
|
|
|
titleEl.className = 'pp-title';
|
|
|
|
|
|
titleEl.textContent = item.title || item.song?.title || '(untitled)';
|
|
|
|
|
|
titleLine.appendChild(titleEl);
|
2026-09-10 15:31:12 -04:00
|
|
|
|
if (!isHeader) {
|
|
|
|
|
|
// Order after the title: key, NOW, type badge, arrangement.
|
|
|
|
|
|
if (on('showKey') && item.keyName) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const key = document.createElement('span');
|
|
|
|
|
|
key.className = 'pp-key';
|
2026-09-27 11:27:31 -04:00
|
|
|
|
// B♭, F♯m — the same notation the Song Key tile uses.
|
|
|
|
|
|
key.textContent = prettyKey(item.keyName);
|
2026-09-09 15:42:53 -04:00
|
|
|
|
titleLine.appendChild(key);
|
|
|
|
|
|
}
|
2026-09-10 15:31:12 -04:00
|
|
|
|
if (on('showNowIndicator')) {
|
|
|
|
|
|
const nowTag = document.createElement('span');
|
|
|
|
|
|
nowTag.className = 'pp-now-tag';
|
|
|
|
|
|
nowTag.textContent = 'Now';
|
|
|
|
|
|
titleLine.appendChild(nowTag);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (on('showItemType') && typeLabel(item.itemType)) {
|
|
|
|
|
|
const badge = document.createElement('span');
|
|
|
|
|
|
badge.className = `pp-badge is-${item.itemType}`;
|
|
|
|
|
|
badge.textContent = typeLabel(item.itemType);
|
|
|
|
|
|
titleLine.appendChild(badge);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (on('showArrangement') && item.arrangement) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const arr = document.createElement('span');
|
|
|
|
|
|
arr.className = 'pp-arr';
|
|
|
|
|
|
arr.textContent = item.arrangement;
|
|
|
|
|
|
titleLine.appendChild(arr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
main.appendChild(titleLine);
|
|
|
|
|
|
|
2026-09-10 15:31:12 -04:00
|
|
|
|
if (!isHeader && on('showSongAuthor') && item.song && (item.song.author || item.song.ccli)) {
|
|
|
|
|
|
const author = document.createElement('div');
|
|
|
|
|
|
author.className = 'pp-author';
|
|
|
|
|
|
author.textContent = [item.song.author, item.song.ccli ? `CCLI ${item.song.ccli}` : ''].filter(Boolean).join(' · ');
|
|
|
|
|
|
main.appendChild(author);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (on('showDescription') && item.description) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const desc = document.createElement('div');
|
|
|
|
|
|
desc.className = 'pp-desc';
|
|
|
|
|
|
desc.textContent = item.description;
|
|
|
|
|
|
main.appendChild(desc);
|
|
|
|
|
|
}
|
2026-09-10 15:31:12 -04:00
|
|
|
|
if (on('showNotes') && item.notes?.length) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const notes = item.notes.filter(notesWanted);
|
|
|
|
|
|
if (notes.length) {
|
|
|
|
|
|
const notesEl = document.createElement('div');
|
|
|
|
|
|
notesEl.className = 'pp-notes';
|
|
|
|
|
|
for (const note of notes) {
|
|
|
|
|
|
const n = document.createElement('div');
|
|
|
|
|
|
n.className = 'pp-note';
|
|
|
|
|
|
if (note.category) {
|
|
|
|
|
|
const cat = document.createElement('span');
|
|
|
|
|
|
cat.className = 'pp-note-cat';
|
|
|
|
|
|
cat.textContent = note.category;
|
|
|
|
|
|
n.appendChild(cat);
|
|
|
|
|
|
}
|
|
|
|
|
|
n.appendChild(document.createTextNode(note.content));
|
|
|
|
|
|
notesEl.appendChild(n);
|
|
|
|
|
|
}
|
|
|
|
|
|
main.appendChild(notesEl);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
row.appendChild(main);
|
|
|
|
|
|
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const wantRuntime = on('showItemRuntime') && state?.live?.following;
|
|
|
|
|
|
if (on('showLength') || wantRuntime) {
|
|
|
|
|
|
const lenCol = document.createElement('div');
|
|
|
|
|
|
lenCol.className = 'pp-col-len';
|
|
|
|
|
|
if (!isHeader) {
|
|
|
|
|
|
if (on('showLength')) {
|
|
|
|
|
|
const len = document.createElement('div');
|
|
|
|
|
|
len.className = 'pp-len';
|
|
|
|
|
|
len.textContent = item.length ? fmtDur(item.length) : '';
|
|
|
|
|
|
lenCol.appendChild(len);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (wantRuntime) {
|
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
|
el.className = 'pp-elapsed';
|
|
|
|
|
|
lenCol.appendChild(el);
|
|
|
|
|
|
}
|
2026-09-09 15:42:53 -04:00
|
|
|
|
}
|
2026-09-10 15:31:12 -04:00
|
|
|
|
row.appendChild(lenCol);
|
2026-09-09 15:42:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
return row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderList() {
|
|
|
|
|
|
const empty = emptyMessage();
|
|
|
|
|
|
if (empty) {
|
|
|
|
|
|
listEl.innerHTML = '';
|
|
|
|
|
|
lastListKey = '';
|
|
|
|
|
|
const box = document.createElement('div');
|
|
|
|
|
|
box.className = 'pp-empty';
|
|
|
|
|
|
const t = document.createElement('div');
|
|
|
|
|
|
t.className = 'pp-empty-title';
|
|
|
|
|
|
t.textContent = empty.title;
|
|
|
|
|
|
box.appendChild(t);
|
|
|
|
|
|
if (empty.hint) {
|
|
|
|
|
|
const h = document.createElement('div');
|
|
|
|
|
|
h.className = 'pp-empty-hint';
|
|
|
|
|
|
h.textContent = empty.hint;
|
|
|
|
|
|
box.appendChild(h);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (empty.link) {
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.className = 'pp-empty-link';
|
|
|
|
|
|
a.href = empty.link.href;
|
|
|
|
|
|
a.target = '_blank';
|
|
|
|
|
|
a.rel = 'noopener';
|
|
|
|
|
|
a.textContent = empty.link.text;
|
|
|
|
|
|
box.appendChild(a);
|
|
|
|
|
|
}
|
|
|
|
|
|
listEl.appendChild(box);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 15:31:12 -04:00
|
|
|
|
// Rebuild rows only when the plan content or the display settings
|
|
|
|
|
|
// changed; live highlighting and timers are applied in place.
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const key = JSON.stringify([view.id, view.plan.id, view.items, cfg(), view.live && state.live?.following]);
|
2026-09-09 15:42:53 -04:00
|
|
|
|
if (key !== lastListKey) {
|
|
|
|
|
|
lastListKey = key;
|
|
|
|
|
|
listEl.innerHTML = '';
|
|
|
|
|
|
const notesWanted = noteFilter();
|
2026-09-10 15:31:12 -04:00
|
|
|
|
let number = 0;
|
|
|
|
|
|
for (const item of visibleItems()) {
|
|
|
|
|
|
if (item.itemType !== 'header') number += 1;
|
|
|
|
|
|
listEl.appendChild(buildRow(item, number, notesWanted));
|
|
|
|
|
|
}
|
2026-09-09 15:42:53 -04:00
|
|
|
|
lastCurrentId = null; // force the highlight + scroll pass
|
|
|
|
|
|
}
|
|
|
|
|
|
renderLiveMarks();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 08:31:26 -04:00
|
|
|
|
/* ── AbleSet: the song playing under the current item ──────────────
|
|
|
|
|
|
Everything here draws only when the server says there is an AbleSet
|
|
|
|
|
|
module, it is configured and reachable, and the song it is playing is
|
|
|
|
|
|
the plan item on screen. Any of those missing and the tile looks
|
|
|
|
|
|
exactly as it did before AbleSet existed. */
|
|
|
|
|
|
|
|
|
|
|
|
/** The playhead now, in beats, carried forward from the last packet. */
|
|
|
|
|
|
function absBeatsNow() {
|
|
|
|
|
|
if (!absView || absView.beats == null) return null;
|
|
|
|
|
|
if (!absView.playing || !absView.tempo) return absView.beats;
|
|
|
|
|
|
const since = Math.max(0, serverNow() - absView.at) / 1000;
|
|
|
|
|
|
return absView.beats + since * (absView.tempo / 60);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function absFraction(part) {
|
|
|
|
|
|
const beats = absBeatsNow();
|
|
|
|
|
|
if (beats == null || !part || part.start == null || part.end == null) return null;
|
|
|
|
|
|
const span = part.end - part.start;
|
|
|
|
|
|
if (!(span > 0)) return null;
|
|
|
|
|
|
return Math.max(0, Math.min(1, (beats - part.start) / span));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The row AbleSet's song belongs to — only ever the current one. */
|
|
|
|
|
|
function absRow() {
|
|
|
|
|
|
const a = absView;
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (!view.live) return null; // another service type is on screen
|
2026-09-27 08:31:26 -04:00
|
|
|
|
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
|
|
|
|
|
|
if (a.itemId !== lastCurrentId) return null;
|
|
|
|
|
|
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Keep the playing section visible in the strip — this strip, nothing else. */
|
|
|
|
|
|
function absScrollSections(strip) {
|
|
|
|
|
|
const active = strip.querySelector('.pp-abs-sec.is-now');
|
|
|
|
|
|
if (!active) return;
|
|
|
|
|
|
const box = strip.getBoundingClientRect();
|
|
|
|
|
|
const rect = active.getBoundingClientRect();
|
|
|
|
|
|
const delta = (rect.left - box.left) - (strip.clientWidth - rect.width) / 2;
|
|
|
|
|
|
if (Math.abs(delta) < 1) return;
|
|
|
|
|
|
strip.scrollTo({ left: strip.scrollLeft + delta, behavior: 'smooth' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderAbleSet() {
|
|
|
|
|
|
const row = absRow();
|
|
|
|
|
|
|
|
|
|
|
|
// Anything left on a row that is no longer the one goes.
|
|
|
|
|
|
for (const stale of listEl.querySelectorAll('.pp-fill, .pp-abs-sections')) {
|
|
|
|
|
|
if (!row || !row.contains(stale)) stale.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!row) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (on('absProgress')) {
|
|
|
|
|
|
const fraction = absFraction(absView.song);
|
|
|
|
|
|
if (fraction == null) {
|
|
|
|
|
|
row.querySelector('.pp-fill')?.remove();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let fill = row.querySelector('.pp-fill');
|
|
|
|
|
|
if (!fill) {
|
|
|
|
|
|
fill = document.createElement('div');
|
|
|
|
|
|
fill.className = 'pp-fill';
|
|
|
|
|
|
row.prepend(fill); // behind the row's content, which the stylesheet lifts
|
|
|
|
|
|
}
|
|
|
|
|
|
fill.style.width = `${(fraction * 100).toFixed(2)}%`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
row.querySelector('.pp-fill')?.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (on('absSections') && absView.sections.length) {
|
|
|
|
|
|
const main = row.querySelector('.pp-col-main');
|
|
|
|
|
|
let strip = row.querySelector('.pp-abs-sections');
|
|
|
|
|
|
if (!strip && main) {
|
|
|
|
|
|
strip = document.createElement('div');
|
|
|
|
|
|
strip.className = 'pp-abs-sections';
|
|
|
|
|
|
main.appendChild(strip);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (strip) {
|
|
|
|
|
|
// The chips are rebuilt only when the song's sections change. Moving
|
|
|
|
|
|
// on to the next section just moves the classes: rebuilding would
|
|
|
|
|
|
// throw the strip's scroll away thirteen times a song.
|
|
|
|
|
|
const key = absView.sections.map((s) => s.name).join('\u0000');
|
|
|
|
|
|
if (strip.dataset.key !== key) {
|
|
|
|
|
|
strip.dataset.key = key;
|
|
|
|
|
|
strip.dataset.now = '';
|
|
|
|
|
|
strip.innerHTML = '';
|
|
|
|
|
|
for (const section of absView.sections) {
|
|
|
|
|
|
const chip = document.createElement('span');
|
|
|
|
|
|
chip.className = 'pp-abs-sec';
|
|
|
|
|
|
chip.textContent = section.name;
|
|
|
|
|
|
strip.appendChild(chip);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const now = absView.section?.index ?? -1;
|
|
|
|
|
|
if (strip.dataset.now !== String(now)) {
|
|
|
|
|
|
strip.dataset.now = String(now);
|
|
|
|
|
|
[...strip.children].forEach((chip, i) => {
|
|
|
|
|
|
chip.classList.toggle('is-now', i === now);
|
|
|
|
|
|
chip.classList.toggle('is-done', i < now);
|
|
|
|
|
|
if (i !== now) chip.style.removeProperty('--pp-sec-fill');
|
|
|
|
|
|
});
|
|
|
|
|
|
requestAnimationFrame(() => absScrollSections(strip));
|
|
|
|
|
|
}
|
|
|
|
|
|
const active = strip.children[now];
|
|
|
|
|
|
if (active) {
|
|
|
|
|
|
const fraction = absFraction(absView.section);
|
|
|
|
|
|
active.style.setProperty('--pp-sec-fill', fraction == null ? '0%' : `${(fraction * 100).toFixed(1)}%`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
row.querySelector('.pp-abs-sections')?.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Centre a row in this tile's own list. `scrollIntoView` would walk up and
|
|
|
|
|
|
* scroll every ancestor that can scroll with it, which on a dashboard taller
|
|
|
|
|
|
* than the window drags the whole board; moving `listEl.scrollTop` moves
|
|
|
|
|
|
* this tile and nothing else. Measured from rectangles rather than
|
|
|
|
|
|
* `offsetTop` because the rows' offset parent is the tile, not the list.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function keepInView(row) {
|
|
|
|
|
|
const list = listEl.getBoundingClientRect();
|
|
|
|
|
|
const rect = row.getBoundingClientRect();
|
|
|
|
|
|
const delta = (rect.top - list.top) - (listEl.clientHeight - rect.height) / 2;
|
|
|
|
|
|
if (Math.abs(delta) < 1) return;
|
|
|
|
|
|
listEl.scrollTo({ top: listEl.scrollTop + delta, behavior: 'smooth' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-09 15:42:53 -04:00
|
|
|
|
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
|
|
|
|
|
function renderLiveMarks() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
// Only the followed service type has a live item: this dashboard may be
|
|
|
|
|
|
// looking at another one, which has no ProPresenter and no clock.
|
|
|
|
|
|
const live = view.live ? (state?.live || {}) : {};
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const now = serverNow();
|
|
|
|
|
|
const currentId = live.following ? live.currentItemId || '' : '';
|
|
|
|
|
|
const history = live.history || {};
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const byId = new Map(view.items.map((it) => [it.id, it]));
|
2026-09-09 15:42:53 -04:00
|
|
|
|
// Everything before the current item counts as done, matched or not.
|
|
|
|
|
|
const doneIds = new Set();
|
|
|
|
|
|
if (currentId) {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
for (const it of view.items) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
if (it.id === currentId) break;
|
|
|
|
|
|
if (it.itemType !== 'header') doneIds.add(it.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const showNow = on('showNowIndicator');
|
|
|
|
|
|
for (const row of listEl.querySelectorAll('.pp-row')) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const id = row.dataset.id;
|
|
|
|
|
|
const item = byId.get(id);
|
2026-09-10 15:31:12 -04:00
|
|
|
|
row.classList.toggle('is-current', showNow && id === currentId);
|
|
|
|
|
|
row.classList.toggle('is-done', on('dimCompleted') && doneIds.has(id));
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const elapsedEl = row.querySelector('.pp-elapsed');
|
|
|
|
|
|
if (!elapsedEl || !item) continue;
|
|
|
|
|
|
const h = history[id];
|
|
|
|
|
|
elapsedEl.className = 'pp-elapsed';
|
|
|
|
|
|
if (!h) {
|
|
|
|
|
|
elapsedEl.textContent = '';
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
const elapsedSec = ((h.endedAt || now) - h.startedAt) / 1000;
|
|
|
|
|
|
elapsedEl.textContent = fmtDur(elapsedSec);
|
|
|
|
|
|
if (item.length) {
|
|
|
|
|
|
const over = elapsedSec - item.length;
|
|
|
|
|
|
if (over > item.length * 0.25 + 30) elapsedEl.classList.add('is-over-hard');
|
|
|
|
|
|
else if (over > 0) elapsedEl.classList.add('is-over');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
|
|
|
|
|
}
|
2026-09-27 08:31:26 -04:00
|
|
|
|
renderAbleSet();
|
2026-09-09 15:42:53 -04:00
|
|
|
|
if (currentId !== lastCurrentId) {
|
|
|
|
|
|
lastCurrentId = currentId;
|
2026-09-10 15:31:12 -04:00
|
|
|
|
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
2026-09-09 15:42:53 -04:00
|
|
|
|
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
2026-09-27 08:31:26 -04:00
|
|
|
|
if (row) keepInView(row);
|
2026-09-09 15:42:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── footer: what ProPresenter is doing ─────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function renderFoot() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const live = view.live ? state?.live : null;
|
|
|
|
|
|
const show = on('showLiveFooter') && live?.following && view.plan;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
footEl.hidden = !show;
|
|
|
|
|
|
footEl.className = 'pp-foot';
|
|
|
|
|
|
if (!show) return;
|
|
|
|
|
|
if (!live.available) {
|
|
|
|
|
|
footEl.classList.add('is-muted');
|
|
|
|
|
|
footEl.textContent = live.reason || 'ProPresenter feed unavailable';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!live.ppEnabled) {
|
|
|
|
|
|
footEl.classList.add('is-muted');
|
|
|
|
|
|
footEl.textContent = 'ProPresenter module has no host configured';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!live.ppReachable) {
|
|
|
|
|
|
footEl.classList.add('is-down');
|
|
|
|
|
|
footEl.textContent = 'ProPresenter unreachable';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!live.activeName) {
|
|
|
|
|
|
footEl.classList.add('is-muted');
|
|
|
|
|
|
footEl.textContent = 'Nothing live in ProPresenter';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (live.matched) {
|
|
|
|
|
|
footEl.classList.add('is-live');
|
|
|
|
|
|
footEl.textContent = `Live: ${live.activeName}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
footEl.classList.add('is-unmatched');
|
|
|
|
|
|
footEl.textContent = `Live: ${live.activeName} — not in the plan`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── render + ticking ───────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function render() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
view = viewOf(state);
|
|
|
|
|
|
switcher.sync();
|
2026-09-09 15:42:53 -04:00
|
|
|
|
wrap.classList.toggle('is-offline', feedOffline);
|
2026-10-06 14:32:19 -04:00
|
|
|
|
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
|
2026-09-10 15:31:12 -04:00
|
|
|
|
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
|
2026-09-10 16:31:46 -04:00
|
|
|
|
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
|
|
|
|
|
|
const color = (value, fallback) => {
|
|
|
|
|
|
const s = String(value || '').trim();
|
|
|
|
|
|
return /^[#a-zA-Z0-9(),.%\s-]+$/.test(s) && s ? s : fallback;
|
|
|
|
|
|
};
|
|
|
|
|
|
wrap.style.setProperty('--pp-key', color(cfg().keyColor, '#4ea1ff'));
|
|
|
|
|
|
wrap.style.setProperty('--pp-header', color(cfg().headerColor, '#2ee59a'));
|
|
|
|
|
|
wrap.style.setProperty('--pp-current', color(cfg().currentColor, '#2ee59a'));
|
2026-09-09 15:42:53 -04:00
|
|
|
|
renderStatus();
|
|
|
|
|
|
if (!state) {
|
|
|
|
|
|
headEl.hidden = true;
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
footEl.hidden = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
renderHead();
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
renderFoot();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyPrefs() {
|
2026-09-10 15:31:12 -04:00
|
|
|
|
const size = Math.min(40, Math.max(9, Number(tilePrefs().textSize) || 14));
|
2026-09-09 15:42:53 -04:00
|
|
|
|
wrap.style.setProperty('--pp-size', `${size}px`);
|
|
|
|
|
|
lastListKey = '';
|
|
|
|
|
|
render();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tick() {
|
2026-09-27 08:31:26 -04:00
|
|
|
|
renderAbleSet();
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (!view.plan) return;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
renderClock();
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (view.live && state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
2026-09-09 15:42:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── live feed ──────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function accept(payload) {
|
|
|
|
|
|
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
|
|
|
|
|
state = payload.state;
|
|
|
|
|
|
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
2026-09-27 08:31:26 -04:00
|
|
|
|
if (payload.ableset) absView = payload.ableset;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
feedOffline = false;
|
|
|
|
|
|
render();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function connect() {
|
|
|
|
|
|
stream = moduleApi.sse('/stream', {
|
|
|
|
|
|
open() {
|
|
|
|
|
|
feedOffline = false;
|
|
|
|
|
|
render();
|
|
|
|
|
|
},
|
|
|
|
|
|
error() {
|
|
|
|
|
|
feedOffline = true;
|
|
|
|
|
|
render();
|
|
|
|
|
|
},
|
|
|
|
|
|
events: {
|
|
|
|
|
|
state(ev) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
accept(JSON.parse(ev.data));
|
|
|
|
|
|
} catch { /* a bad frame is not fatal */ }
|
|
|
|
|
|
},
|
2026-09-27 08:31:26 -04:00
|
|
|
|
// Its own event, several a second while a song plays: redraw that one
|
|
|
|
|
|
// row rather than the whole list.
|
|
|
|
|
|
ableset(ev) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
absView = JSON.parse(ev.data);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state) renderAbleSet();
|
|
|
|
|
|
},
|
2026-09-09 15:42:53 -04:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onUserScroll() {
|
|
|
|
|
|
userScrolledAt = Date.now();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── lifecycle ──────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
start() {
|
|
|
|
|
|
applyPrefs();
|
|
|
|
|
|
moduleApi.setStatus('connecting', 'Connecting…');
|
|
|
|
|
|
moduleApi.fetch('/state')
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
|
|
|
|
.then((body) => { if (body && !state) accept(body); })
|
|
|
|
|
|
.catch(() => { /* the stream will cover it */ });
|
|
|
|
|
|
connect();
|
2026-10-06 14:32:19 -04:00
|
|
|
|
offViewed = onViewedType(() => { lastListKey = ''; render(); });
|
2026-09-09 15:42:53 -04:00
|
|
|
|
listEl.addEventListener('wheel', onUserScroll, { passive: true });
|
|
|
|
|
|
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
|
|
|
|
|
|
ticker = setInterval(tick, 1000);
|
|
|
|
|
|
},
|
|
|
|
|
|
stop() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
offViewed?.();
|
|
|
|
|
|
offViewed = null;
|
2026-09-09 15:42:53 -04:00
|
|
|
|
clearInterval(ticker);
|
|
|
|
|
|
ticker = null;
|
|
|
|
|
|
stream?.close();
|
|
|
|
|
|
stream = null;
|
|
|
|
|
|
listEl.removeEventListener('wheel', onUserScroll);
|
|
|
|
|
|
listEl.removeEventListener('touchmove', onUserScroll);
|
|
|
|
|
|
root.innerHTML = '';
|
|
|
|
|
|
},
|
|
|
|
|
|
onConfigChange() {
|
2026-09-10 16:20:46 -04:00
|
|
|
|
// Admin holds only connection settings; the server re-inits and its
|
|
|
|
|
|
// stream re-sends the state. Nothing cached here to refresh.
|
2026-09-09 15:42:53 -04:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|