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');