Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden
Time left on the video ProPresenter is playing — the one number a booth
needs during a bumper or sermon video — was missing: the module only read
the configured timers, and the video countdown lives in the transport
routes. Verified live on ProPresenter 21.4:
GET /v1/transport/presentation/current → { is_playing, uuid, name,
artist, audio_only, duration }
GET /v1/transport/presentation/time → bare seconds as a sub-second
float — better than /v1/timer/video_countdown's whole-second string,
so no quantization phase lock is needed, only an anchor hold (a frame
is broadcast only when the anchor drifts, not per poll).
The Video card rides the ProPresenter source: transport current on the
slow lane, position on the hot path (skipped while no media is loaded),
running/paused/idle from is_playing and position, the media name (minus
its file extension) as the detail line. It persists as an idle '—'
between videos — a wall tile must not appear and vanish with the media —
and is absent only on a ProPresenter whose transport routes 404.
No looping flag exists anywhere in the API, so looping is inferred: the
same media's position jumping backwards is a wrap, and the flag sticks
until the media changes. Each tile gets a 'Looping videos' switch
(default off): off, a looping background's meaningless time is hidden —
the card sits out of a multi-card tile, and a solo Video tile shows a
quiet 'Looping' card instead of sitting empty. Shown loops never take
the warn/overrun tones: a 15 s background flashing red every wrap would
cry wolf all service.
The picker offers 'Video' as its own tile (solo gear carries the loop
switch); pp-timers-mock gains the transport routes with a 4-minute cycle
(bumper playing → paused → resumed → nothing → looping background).
Verified in the browser across the whole cycle, both tile kinds, and the
per-tile toggle. Selftests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b3b7b29007
commit
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
|
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||||
its item tags, plus tidy titles.
|
its item tags, plus tidy titles.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
elapsed), the **video countdown** (time left on what ProPresenter is
|
||||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
playing, with a per-tile "Looping videos" switch so looping backgrounds
|
||||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
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
|
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||||
three groups: **ProPresenter** (endpoint host : port, API password),
|
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||||
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const DEFAULTS = {
|
|||||||
showDetail: true,
|
showDetail: true,
|
||||||
showSeconds: true,
|
showSeconds: true,
|
||||||
leadingZeros: false,
|
leadingZeros: false,
|
||||||
|
showLoops: false,
|
||||||
clock24h: true,
|
clock24h: true,
|
||||||
clockSeconds: true,
|
clockSeconds: true,
|
||||||
clockDate: false,
|
clockDate: false,
|
||||||
@@ -384,7 +385,12 @@ export default function create({ root, moduleApi }) {
|
|||||||
for (const source of state.sources) {
|
for (const source of state.sources) {
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
if (keyOf(source, timer) !== solo) continue;
|
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
|
// 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 = [];
|
const items = [];
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
const key = keyOf(source, timer);
|
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.
|
// Every timer of a live source hidden on purpose: nothing to say.
|
||||||
if (!items.length && source.timers.length) continue;
|
if (!items.length && source.timers.length) continue;
|
||||||
@@ -666,7 +676,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.mono = null;
|
card.mono = null;
|
||||||
}
|
}
|
||||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
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);
|
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||||
statusTone = timer.status?.tone || tone;
|
statusTone = timer.status?.tone || tone;
|
||||||
detailText = timer.detail || '';
|
detailText = timer.detail || '';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.5.1",
|
"version": "1.6.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -161,6 +161,12 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"group": "Show"
|
"group": "Show"
|
||||||
},
|
},
|
||||||
|
"showLoops": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Looping videos",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
"showHeadings": {
|
"showHeadings": {
|
||||||
"type": "switch",
|
"type": "switch",
|
||||||
"label": "Source headings",
|
"label": "Source headings",
|
||||||
|
|||||||
@@ -255,6 +255,21 @@ function createProPresenterSource({ config, log }) {
|
|||||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||||
const phases = new Map();
|
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) {
|
function url(target) {
|
||||||
const built = new URL(`http://${host}:${port}${target}`);
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
if (password) built.searchParams.set('password', password);
|
if (password) built.searchParams.set('password', password);
|
||||||
@@ -390,6 +405,121 @@ function createProPresenterSource({ config, log }) {
|
|||||||
return out;
|
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() {
|
function status() {
|
||||||
if (!configured) return 'error';
|
if (!configured) return 'error';
|
||||||
if (reachable) return 'ok';
|
if (reachable) return 'ok';
|
||||||
@@ -429,6 +559,9 @@ function createProPresenterSource({ config, log }) {
|
|||||||
const currentTimes = await requestJson('/v1/timers/current');
|
const currentTimes = await requestJson('/v1/timers/current');
|
||||||
updatedAt = Date.now();
|
updatedAt = Date.now();
|
||||||
timers = mergeTimers(currentTimes, updatedAt);
|
timers = mergeTimers(currentTimes, updatedAt);
|
||||||
|
await pollVideo();
|
||||||
|
const video = videoTimer();
|
||||||
|
if (video) timers.push(video);
|
||||||
reachable = true;
|
reachable = true;
|
||||||
lastError = '';
|
lastError = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -970,6 +1103,8 @@ const GEAR = {
|
|||||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
||||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
||||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
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. */
|
/** 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),
|
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
||||||
/** The time of day: plain digits, so only how they read. */
|
/** 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 === 'ltc') return GEAR.ltc;
|
||||||
if (source.kind === 'clock') return GEAR.clock;
|
if (source.kind === 'clock') return GEAR.clock;
|
||||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
if (timer.kind === 'clock') return GEAR.providerClock;
|
||||||
|
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
|
||||||
return GEAR.timer;
|
return GEAR.timer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,6 +1327,7 @@ module.exports = {
|
|||||||
for (const t of src.timers) {
|
for (const t of src.timers) {
|
||||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
||||||
: src.kind === 'clock' ? 'The time of day on its own'
|
: src.kind === 'clock' ? 'The time of day 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`;
|
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||||
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
// 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'] : [];
|
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
||||||
|
|||||||
@@ -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') 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/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/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);
|
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) ─────────────────────── */
|
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||||
|
|
||||||
const cryptoMod = require('crypto');
|
const cryptoMod = require('crypto');
|
||||||
|
|||||||
Reference in New Issue
Block a user