mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Self-contained examples for the PiP overlay API (POST /api/pip), each with a CSP-safe query-param overlay (external JS), config.example.json, zero runtime deps, an offline test, and a README: - PIP-Announce-Broadcast manual one-shot message to a screen/group - PIP-Weather-Widget Open-Meteo current conditions (keyless) - PIP-Air-Quality Open-Meteo US AQI widget (keyless) - PIP-Crypto-Ticker CoinGecko price strip (keyless) - PIP-News-Ticker scrolling RSS/Atom headlines - PIP-Room-Status-Calendar ICS-driven Available/Busy room sign - PIP-Event-Countdown client-side countdown, auto-clears at zero - PIP-Welcome-Board rotating welcome/birthday cards from CSV - PIP-Fundraiser-Thermometer goal-progress bar from local/URL JSON - PIP-QR-Rotator rotating QR codes, encoded client-side - PIP-Incident-Webhook event-driven: red on firing, clear on resolved Also includes the CAP-AU (NSW RFS) and US NWS/NOAA emergency-alert monitors that push expiry-aware PiP overlays. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
6 KiB
JavaScript
157 lines
6 KiB
JavaScript
'use strict';
|
|
|
|
// Countdown -> ScreenTinker PiP. Pushes ONE live countdown overlay to a device or
|
|
// group and lets the player auto-clear it the instant the target time arrives, using
|
|
// the PiP `duration` field (duration = seconds-to-target, so no clear poll is needed).
|
|
//
|
|
// node countdown.js [path/to/config.json]
|
|
// node countdown.js "2026-12-31T23:59:59-06:00" "Happy New Year" # CLI override
|
|
// node countdown.js [config] --clear # clear it early
|
|
//
|
|
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const PIP_DUR_MAX = 86400; // PiP API duration cap (seconds)
|
|
|
|
// --- pure, testable helpers (no I/O, explicit `now` so tests are deterministic) ---
|
|
|
|
// Whole seconds from `now` until `target` (both epoch ms), rounded UP so the last
|
|
// partial second still counts. <= 0 means the moment has already passed.
|
|
function secondsToTarget(target, now) {
|
|
return Math.ceil((target - now) / 1000);
|
|
}
|
|
|
|
// Split a non-negative second count into d/h/m/s. Negative clamps to zero.
|
|
function breakdown(seconds) {
|
|
let s = Math.max(0, Math.floor(seconds));
|
|
const days = Math.floor(s / 86400); s -= days * 86400;
|
|
const hours = Math.floor(s / 3600); s -= hours * 3600;
|
|
const minutes = Math.floor(s / 60); s -= minutes * 60;
|
|
return { days, hours, minutes, seconds: s };
|
|
}
|
|
|
|
// PiP duration to request: seconds-to-target, but never above the API cap. For targets
|
|
// more than 24h out the overlay won't auto-clear at zero (it'd hit the cap first); the
|
|
// CLI warns in that case. 0 would mean "until cleared", which we never want here.
|
|
function durationForTarget(seconds) {
|
|
return Math.max(1, Math.min(seconds, PIP_DUR_MAX));
|
|
}
|
|
|
|
module.exports = { secondsToTarget, breakdown, durationForTarget, PIP_DUR_MAX };
|
|
|
|
// --- CLI ---
|
|
|
|
if (require.main === module) main();
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
const clear = args.includes('--clear');
|
|
const positional = args.filter(a => !a.startsWith('--'));
|
|
|
|
// First positional that isn't an ISO date is treated as the config path.
|
|
let cfgPath = path.join(__dirname, 'config.json');
|
|
let cliTarget = null, cliTitle = null;
|
|
if (positional.length && Number.isFinite(Date.parse(positional[0]))) {
|
|
cliTarget = positional[0];
|
|
cliTitle = positional[1] || null;
|
|
} else if (positional.length) {
|
|
cfgPath = positional[0];
|
|
if (positional[1] && Number.isFinite(Date.parse(positional[1]))) {
|
|
cliTarget = positional[1];
|
|
cliTitle = positional[2] || null;
|
|
}
|
|
}
|
|
|
|
let cfg = {};
|
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
|
|
catch (e) {
|
|
if (!cliTarget) { console.error(`Could not read config at ${cfgPath}: ${e.message}`); process.exit(1); }
|
|
}
|
|
|
|
const apiBase = (cfg.api_base || '').replace(/\/$/, '');
|
|
const apiToken = cfg.api_token;
|
|
const overlayBase = cfg.overlay_base_url;
|
|
const deviceId = cfg.device_id;
|
|
const targetIso = cliTarget || cfg.target;
|
|
const title = cliTitle || cfg.title || 'Countdown';
|
|
const position = cfg.position || 'center';
|
|
|
|
if (!apiBase || !apiToken || !deviceId) {
|
|
console.error('config must set api_base, api_token, and device_id.');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (clear) { return doClear(apiBase, apiToken, deviceId); }
|
|
|
|
if (!overlayBase) { console.error('config must set overlay_base_url for a countdown overlay.'); process.exit(1); }
|
|
const targetMs = Date.parse(targetIso);
|
|
if (!Number.isFinite(targetMs)) { console.error(`invalid target datetime: ${targetIso}`); process.exit(1); }
|
|
|
|
const now = Date.now();
|
|
const secs = secondsToTarget(targetMs, now);
|
|
if (secs <= 0) {
|
|
console.log(`"${title}" target ${targetIso} has already passed — nothing to show.`);
|
|
process.exit(0);
|
|
}
|
|
if (secs > PIP_DUR_MAX) {
|
|
const b = breakdown(secs);
|
|
console.warn(`note: target is ${b.days}d ${b.hours}h away (> 24h). The overlay will show but auto-clear caps at 24h; re-run within 24h of the target for the self-clear-at-zero effect.`);
|
|
}
|
|
|
|
showCountdown({ apiBase, apiToken, deviceId, overlayBase, targetMs, title, position, secs });
|
|
}
|
|
|
|
function overlayUri(overlayBase, targetMs, title) {
|
|
const q = new URLSearchParams({ target: String(targetMs), title: title || '' });
|
|
return `${overlayBase}${overlayBase.includes('?') ? '&' : '?'}${q.toString()}`;
|
|
}
|
|
|
|
async function showCountdown({ apiBase, apiToken, deviceId, overlayBase, targetMs, title, position, secs }) {
|
|
const duration = durationForTarget(secs);
|
|
const body = {
|
|
device_id: deviceId,
|
|
type: 'web',
|
|
uri: overlayUri(overlayBase, targetMs, title),
|
|
position,
|
|
width: 820,
|
|
height: 300,
|
|
duration,
|
|
border_radius: 16,
|
|
close_button: false,
|
|
title,
|
|
};
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/pip`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const json = await res.json().catch(() => ({}));
|
|
if (!res.ok || !json.pip_id) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
|
const b = breakdown(secs);
|
|
console.log(`SHOW "${title}" pip=${json.pip_id} target=${new Date(targetMs).toISOString()}`);
|
|
console.log(`auto-clears in ${secs}s (${b.days}d ${b.hours}h ${b.minutes}m ${b.seconds}s) — player drops it at zero, no clear call needed.`);
|
|
} catch (e) {
|
|
console.error(`pip show failed: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function doClear(apiBase, apiToken, deviceId) {
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/pip/clear`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
|
|
body: JSON.stringify({ device_id: deviceId }),
|
|
});
|
|
const json = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
|
console.log(`CLEAR sent to ${deviceId} (sent=${json.sent ?? '?'} offline=${json.offline ?? '?'})`);
|
|
} catch (e) {
|
|
console.error(`pip clear failed: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|