From 86a3270f1c066d9e78e36d3bc1f47502c48f7ef2 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 27 Sep 2026 11:32:36 -0400 Subject: [PATCH] Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 8 +- modules/propresenter-timers/client.js | 18 ++- modules/propresenter-timers/module.json | 8 +- modules/propresenter-timers/server.js | 139 +++++++++++++++++++++++- tools/pp-timers-mock.js | 29 +++++ 5 files changed, 194 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 85baf5e..f425f4b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 7b48798..ff37685 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -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 || ''; diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 7e17779..a2b9d43 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -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", diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index f282ddc..4235b14 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -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:' / '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 } : {}) }); diff --git a/tools/pp-timers-mock.js b/tools/pp-timers-mock.js index c687e56..5fe657f 100644 --- a/tools/pp-timers-mock.js +++ b/tools/pp-timers-mock.js @@ -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');