Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a3270f1c |
@@ -303,9 +303,11 @@ below. Nothing is installed without someone clicking.
|
||||
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||
its item tags, plus tidy titles.
|
||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||
elapsed), the **video countdown** (time left on what ProPresenter is
|
||||
playing, with a per-tile "Looping videos" switch so looping backgrounds
|
||||
stay hidden) and LTC timecode as auto-sized cards. The + picker lists
|
||||
every discovered timer (and the LTC card) as its own tile, or add "All
|
||||
timers" and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||
|
||||
@@ -70,6 +70,7 @@ const DEFAULTS = {
|
||||
showDetail: true,
|
||||
showSeconds: true,
|
||||
leadingZeros: false,
|
||||
showLoops: false,
|
||||
clock24h: true,
|
||||
clockSeconds: true,
|
||||
clockDate: false,
|
||||
@@ -384,7 +385,12 @@ export default function create({ root, moduleApi }) {
|
||||
for (const source of state.sources) {
|
||||
for (const timer of source.timers) {
|
||||
if (keyOf(source, timer) !== solo) continue;
|
||||
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
||||
// A solo video tile with looping media hidden must not sit empty:
|
||||
// it shows a quiet card instead of a countdown nobody should read.
|
||||
const shown = timer.looping && pref('showLoops') !== true
|
||||
? { ...timer, state: 'idle', remainingMs: null, elapsedMs: null, startedAt: null, asOf: null, status: { text: 'Looping', tone: 'muted' } }
|
||||
: timer;
|
||||
return [{ id: source.id, source, items: [{ key: solo, source, timer: shown }] }];
|
||||
}
|
||||
}
|
||||
// The timer this tile was added for is not offered right now. Nothing is
|
||||
@@ -398,7 +404,11 @@ export default function create({ root, moduleApi }) {
|
||||
const items = [];
|
||||
for (const timer of source.timers) {
|
||||
const key = keyOf(source, timer);
|
||||
if (!hidden.has(key)) items.push({ key, source, timer });
|
||||
if (hidden.has(key)) continue;
|
||||
// A looping background's time means nothing — the card sits out
|
||||
// while a loop plays (per tile: the "Looping videos" switch).
|
||||
if (timer.looping && pref('showLoops') !== true) continue;
|
||||
items.push({ key, source, timer });
|
||||
}
|
||||
// Every timer of a live source hidden on purpose: nothing to say.
|
||||
if (!items.length && source.timers.length) continue;
|
||||
@@ -666,7 +676,9 @@ export default function create({ root, moduleApi }) {
|
||||
card.mono = null;
|
||||
}
|
||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||
tone = toneFor(timer, value);
|
||||
// A shown loop never warns or overruns — it wraps at zero by design,
|
||||
// and a background flashing red every pass would cry wolf all service.
|
||||
tone = timer.looping ? (timer.state === 'running' ? 'ok' : 'muted') : toneFor(timer, value);
|
||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||
statusTone = timer.status?.tone || tone;
|
||||
detailText = timer.detail || '';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
@@ -161,6 +161,12 @@
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLoops": {
|
||||
"type": "switch",
|
||||
"label": "Looping videos",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showHeadings": {
|
||||
"type": "switch",
|
||||
"label": "Source headings",
|
||||
|
||||
@@ -255,6 +255,21 @@ function createProPresenterSource({ config, log }) {
|
||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||
const phases = new Map();
|
||||
|
||||
/* The video countdown — time left on what the presentation layer is
|
||||
playing, from the transport routes (verified live on ProPresenter 21.4):
|
||||
GET /v1/transport/presentation/current → { is_playing, uuid, name,
|
||||
artist, audio_only, duration } (no looping flag exists — inferred)
|
||||
GET /v1/transport/presentation/time → bare seconds, sub-second
|
||||
float — better than /v1/timer/video_countdown's whole-second string,
|
||||
so no quantization phase lock is needed, only an anchor hold. */
|
||||
let media = null; // { uuid, name, duration, playing } from the slow lane
|
||||
let mediaAt = 0;
|
||||
let transportOk = null; // null until the first answer; false = no transport route (older ProPresenter)
|
||||
let videoPos = null; // playback position in seconds (float)
|
||||
let videoAsOf = 0; // server ms when videoPos was measured
|
||||
let videoAnchor = null; // published { remainingMs, asOf }, held while it predicts the measurements
|
||||
let loopingUuid = ''; // media seen wrapping — a looping background
|
||||
|
||||
function url(target) {
|
||||
const built = new URL(`http://${host}:${port}${target}`);
|
||||
if (password) built.searchParams.set('password', password);
|
||||
@@ -390,6 +405,121 @@ function createProPresenterSource({ config, log }) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the presentation transport layer. Failures here only drop the
|
||||
* video card, never the whole source — the timers must survive a
|
||||
* ProPresenter build with transport quirks.
|
||||
*/
|
||||
async function pollVideo() {
|
||||
if (tick % SLOW_EVERY === 1) {
|
||||
try {
|
||||
const raw = await requestJson('/v1/transport/presentation/current');
|
||||
const uuid = String(raw?.uuid || '');
|
||||
if (!media || media.uuid !== uuid) {
|
||||
if (media && media.uuid !== uuid) loopingUuid = '';
|
||||
videoPos = null;
|
||||
videoAnchor = null;
|
||||
}
|
||||
media = uuid ? {
|
||||
uuid,
|
||||
name: String(raw.name || ''),
|
||||
duration: Number(raw.duration) > 0 ? Number(raw.duration) : 0,
|
||||
playing: Boolean(raw.is_playing),
|
||||
} : null;
|
||||
mediaAt = Date.now();
|
||||
transportOk = true;
|
||||
} catch (err) {
|
||||
media = null; // the slow lane retries in ~2 s
|
||||
if (err && err.status === 404) transportOk = false; // no transport route on this build
|
||||
}
|
||||
}
|
||||
if (!media) {
|
||||
videoPos = null;
|
||||
videoAnchor = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pos = Number(await requestJson('/v1/transport/presentation/time'));
|
||||
if (Number.isFinite(pos) && pos >= 0) {
|
||||
// Same media, position jumped back by more than a stutter: it
|
||||
// wrapped — a looping background. Sticks until the media changes.
|
||||
// (A video manually re-triggered from the start looks the same and
|
||||
// is mislabeled for that pass; loops re-wrap, replays don't recur.)
|
||||
if (videoPos !== null && media.playing && videoPos - pos > 1.5) loopingUuid = media.uuid;
|
||||
videoPos = pos;
|
||||
videoAsOf = Date.now();
|
||||
} else {
|
||||
videoPos = null;
|
||||
}
|
||||
} catch {
|
||||
videoPos = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The video countdown as one synthetic timer card (id 'video'). The card
|
||||
persists as an idle "—" between videos — a wall tile must not appear
|
||||
and vanish with the media — and is only absent entirely on a
|
||||
ProPresenter without transport routes. */
|
||||
function videoTimer() {
|
||||
if (transportOk !== true) return null;
|
||||
if (!media) {
|
||||
return {
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
kind: 'countdown',
|
||||
state: 'idle',
|
||||
asOf: mediaAt || updatedAt,
|
||||
startedAt: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: null,
|
||||
targetMs: null,
|
||||
looping: false,
|
||||
status: null,
|
||||
detail: '',
|
||||
};
|
||||
}
|
||||
const playing = media.playing;
|
||||
const dur = media.duration > 0 ? media.duration * 1000 : null;
|
||||
let remainingMs = null;
|
||||
let asOf = videoAsOf || mediaAt;
|
||||
if (videoPos !== null && dur !== null) {
|
||||
remainingMs = Math.max(0, dur - videoPos * 1000);
|
||||
if (playing) {
|
||||
// Hold the published anchor while it still predicts the measured
|
||||
// position: the tiles extrapolate locally, and a fresh anchor per
|
||||
// poll would broadcast a frame every 500 ms for nothing.
|
||||
const p = videoAnchor;
|
||||
const predicted = p ? p.remainingMs - (videoAsOf - p.asOf) : NaN;
|
||||
if (p && Math.abs(predicted - remainingMs) < 120) {
|
||||
remainingMs = p.remainingMs;
|
||||
asOf = p.asOf;
|
||||
} else {
|
||||
videoAnchor = { remainingMs, asOf };
|
||||
}
|
||||
} else {
|
||||
videoAnchor = null;
|
||||
}
|
||||
} else {
|
||||
videoAnchor = null;
|
||||
}
|
||||
const looping = media.uuid === loopingUuid;
|
||||
return {
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
kind: 'countdown',
|
||||
state: playing ? 'running' : videoPos > 0 ? 'paused' : 'idle',
|
||||
asOf,
|
||||
startedAt: null,
|
||||
elapsedMs: remainingMs !== null && dur !== null ? dur - remainingMs : null,
|
||||
remainingMs,
|
||||
targetMs: dur,
|
||||
looping,
|
||||
status: looping ? { text: playing ? 'Looping' : 'Paused', tone: 'muted' } : null,
|
||||
// The media name, without the file extension nobody reads on a wall.
|
||||
detail: media.name.replace(/\.(mp4|mov|m4v|mxf|avi|mkv|mp3|wav|aiff?)$/i, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function status() {
|
||||
if (!configured) return 'error';
|
||||
if (reachable) return 'ok';
|
||||
@@ -429,6 +559,9 @@ function createProPresenterSource({ config, log }) {
|
||||
const currentTimes = await requestJson('/v1/timers/current');
|
||||
updatedAt = Date.now();
|
||||
timers = mergeTimers(currentTimes, updatedAt);
|
||||
await pollVideo();
|
||||
const video = videoTimer();
|
||||
if (video) timers.push(video);
|
||||
reachable = true;
|
||||
lastError = '';
|
||||
} catch (err) {
|
||||
@@ -970,6 +1103,8 @@ const GEAR = {
|
||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
||||
/** The video countdown: a timer card, plus whether looping media counts. */
|
||||
video: gearOf([...TIMER_ELEMENTS, 'showLoops', ...COLOURS], OPEN_COLOURS),
|
||||
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
||||
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
||||
/** The time of day: plain digits, so only how they read. */
|
||||
@@ -983,6 +1118,7 @@ function gearFor(source, timer) {
|
||||
if (source.kind === 'ltc') return GEAR.ltc;
|
||||
if (source.kind === 'clock') return GEAR.clock;
|
||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
||||
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
|
||||
return GEAR.timer;
|
||||
}
|
||||
|
||||
@@ -1191,7 +1327,8 @@ module.exports = {
|
||||
for (const t of src.timers) {
|
||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
||||
: src.kind === 'clock' ? 'The time of day on its own'
|
||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||
: src.id === 'propresenter' && t.id === 'video' ? 'Time left on the video ProPresenter is playing'
|
||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
||||
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
||||
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
|
||||
|
||||
@@ -152,10 +152,39 @@ const server = http.createServer((req, res) => {
|
||||
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
||||
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
||||
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
||||
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
|
||||
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
|
||||
|
||||
sendJson(res, { error: 'not found' }, 404);
|
||||
});
|
||||
|
||||
/* ── transport (video countdown): a 4-minute cycle ─────────────────
|
||||
0–60 s "Sermon Bumper.mp4" (3:00) playing
|
||||
60–75 s the same bumper paused at 1:00
|
||||
75–90 s playing again
|
||||
90–120 s nothing loaded
|
||||
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
|
||||
must infer looping from the position jumping back.
|
||||
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
|
||||
name, artist, audio_only, duration }, `time` = bare seconds (float). */
|
||||
function transport() {
|
||||
const phase = ((Date.now() - startedAt) / 1000) % 240;
|
||||
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
|
||||
if (phase < 90) {
|
||||
const playing = phase < 60 || phase >= 75;
|
||||
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
|
||||
return {
|
||||
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
|
||||
time: Math.round(pos * 10) / 10,
|
||||
};
|
||||
}
|
||||
if (phase < 120) return none;
|
||||
return {
|
||||
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
|
||||
time: Math.round(((phase - 120) % 15) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||
|
||||
const cryptoMod = require('crypto');
|
||||
|
||||
Reference in New Issue
Block a user