Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a3270f1c | ||
|
|
b3b7b29007 |
@@ -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