Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 f0be37796b Slack 1.2.1: a clearer Send tile, and a send target you can see
The line above the message box said "Sending as Apollo Beach → #ops" with a
Lock button beside it. The box's own placeholder already reads "Message
#ops…" and the title bar already has a Lock, so both went. Quick replies
keeps its line: it has no placeholder, so that is the only place its target
is named.

The bigger thing behind it: until a transcript was clicked, a send went to
the first channel in the admin list whether or not that channel was on the
dashboard — so a board showing only #ops would quietly aim at #general. The
target now falls to the first configured channel whose transcript is
actually up, which with a single transcript is that one. Only when the page
shows no transcript at all does it fall back to the first configured
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:58:57 -04:00
5 changed files with 15 additions and 145 deletions
-6
View File
@@ -231,13 +231,7 @@ below. Nothing is installed without someone clicking.
Tile settings: channel icons, direction and timestamps. Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
the live item alone with the gradient filling the tile edge to edge — a
wall panel that says one thing.
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
Tile settings: a **Show** group of switches for the playlist name, live
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, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every 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" discovered timer (and the LTC card) as its own tile, or add "All timers"
+9 -39
View File
@@ -6,12 +6,6 @@
in /admin; the tile only renders. */ in /admin; the tile only renders. */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
let stream = null; let stream = null;
@@ -39,13 +33,11 @@ export default function create({ root, moduleApi }) {
</div>`; </div>`;
const screenEl = root.querySelector('.nn-screen'); const screenEl = root.querySelector('.nn-screen');
if (nowOnly) screenEl.classList.add('nn-only-now');
const topRow = root.querySelector('.nn-top'); const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist'); const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live'); const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text'); const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now'); const nowEl = root.querySelector('.nn-now');
const nowLabelEl = root.querySelector('.nn-label');
const nowTitleBox = root.querySelector('.nn-title-box'); const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title'); let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta'); const nowMetaEl = root.querySelector('.nn-meta');
@@ -55,11 +47,9 @@ export default function create({ root, moduleApi }) {
const nextListEl = root.querySelector('.nn-next-list'); const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more'); const nextMoreEl = root.querySelector('.nn-next-more');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() { function prefs() {
return moduleApi.instanceSettings; return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
} }
const on = (key) => prefs()[key] !== false;
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
@@ -75,7 +65,7 @@ export default function create({ root, moduleApi }) {
} }
function displayName(raw) { function displayName(raw) {
return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim(); return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
} }
function itemTypeTag(type) { function itemTypeTag(type) {
@@ -317,9 +307,7 @@ export default function create({ root, moduleApi }) {
const total = state.currentSlideTotal || activeItem?.slideCount || 0; const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0; const current = state.currentSlide || 0;
if (!on('showSlideCount')) { if (total > 0 && current > 0) {
nowMetaEl.textContent = '';
} else if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current); const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) { } else if (total > 0) {
@@ -330,7 +318,7 @@ export default function create({ root, moduleApi }) {
nowMetaEl.textContent = ''; nowMetaEl.textContent = '';
} }
if (on('showProgress') && total > 0) { if (prefs().showProgress && total > 0) {
progressEl.hidden = false; progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`; progressFillEl.style.width = `${pct}%`;
@@ -361,10 +349,6 @@ export default function create({ root, moduleApi }) {
nameEl.textContent = displayName(item.name) || '(untitled)'; nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl); row.appendChild(nameEl);
if (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div'); const chip = document.createElement('div');
chip.className = 'nn-count-chip'; chip.className = 'nn-count-chip';
@@ -382,7 +366,7 @@ export default function create({ root, moduleApi }) {
nextListEl.appendChild(row); nextListEl.appendChild(row);
} }
nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel'); nextLabelEl.hidden = upcoming.length === 0;
nextMoreEl.hidden = true; nextMoreEl.hidden = true;
markNextOverflow(); markNextOverflow();
requestAnimationFrame(markNextOverflow); requestAnimationFrame(markNextOverflow);
@@ -390,22 +374,12 @@ export default function create({ root, moduleApi }) {
function render() { function render() {
screenEl.classList.toggle('is-offline', feedOffline); screenEl.classList.toggle('is-offline', feedOffline);
// Trouble always shows, whatever is switched off: a tile that has stopped const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
// updating must never look like one with nothing live. topRow.classList.toggle('hidden-row', !showTop);
const trouble = feedOffline || !state?.reachable || !state?.enabled; playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive(); renderLive();
renderNow(); renderNow();
if (!nowOnly && on('showNext')) renderNext(); renderNext();
} }
/* ── sizing: --unit scales the whole card to the tile ───────────── */ /* ── sizing: --unit scales the whole card to the tile ───────────── */
@@ -413,10 +387,6 @@ export default function create({ root, moduleApi }) {
function applyUnit() { function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px'); screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
} }
/* ── live feed ──────────────────────────────────────────────────── */ /* ── live feed ──────────────────────────────────────────────────── */
+6 -15
View File
@@ -1,8 +1,8 @@
{ {
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter", "name": "ProPresenter",
"version": "1.1.0", "version": "1.0.1",
"proddash": ">=1.5.0", "proddash": ">=1.1.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"permissions": [ "permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } { "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
@@ -20,22 +20,13 @@
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "Password", "label": "API password (only if set in ProPresenter)",
"default": "" "default": ""
} }
}, },
"instanceSchema": { "instanceSchema": {
"cleanTitles": { "type": "switch", "label": "Tidy titles", "default": true }, "showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
"showPlaylist": { "type": "switch", "label": "Playlist", "default": true, "group": "Show" }, "showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
"showLive": { "type": "switch", "label": "Live status", "default": true, "group": "Show" }, "cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
"showNowLabel": { "type": "switch", "label": "Now label", "default": true, "group": "Show" },
"showSlideCount": { "type": "switch", "label": "Slide count", "default": true, "group": "Show" },
"showProgress": { "type": "switch", "label": "Progress bar", "default": true, "group": "Show" },
"showNext": { "type": "switch", "label": "Next list", "default": true, "group": "Show" },
"showNextLabel": { "type": "switch", "label": "Next label", "default": true, "group": "Show" },
"showTags": { "type": "switch", "label": "Item tags", "default": true, "group": "Show" }
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }
} }
} }
-37
View File
@@ -108,43 +108,6 @@ module.exports = {
}; };
}, },
/**
* Two ways to place this module. "Now / Next" is the full card — what is
* live, then the rest of the playlist. "Now" is the same live item alone,
* edge to edge with the gradient filling the tile: a wall display that says
* one thing. The Now entry offers only the settings it can act on, so its
* gear carries no Next switches.
*/
tiles() {
return [
{
id: 'full',
name: 'Now / Next',
description: 'What is live, and the rest of the playlist',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
settings: { view: 'full' },
},
{
id: 'now',
name: 'Now',
description: 'Only what is live, filling the tile',
defaultSize: { w: 4, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'now' },
instanceSchema: {
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
},
instanceGroups: { Show: { collapsed: false, columns: 2 } },
},
];
},
routes() { routes() {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
-48
View File
@@ -21,51 +21,6 @@
justify-content: center; justify-content: center;
} }
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
/* ── the "Now" tile ──
Only what is live, edge to edge: the gradient moves off the card and onto
the whole tile, the card loses its border, radius and box, and the title
sits in the middle of everything. Nothing is inset, so a wall of these
reads as panels of colour rather than cards on a background. */
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
gap: 0;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 14%, transparent),
color-mix(in srgb, var(--bg) 70%, transparent)
);
}
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
flex: 1;
height: auto;
min-height: 0;
justify-content: center;
border: none;
border-radius: 0;
background: none;
padding: 0;
}
/* The live dot and playlist ride over the gradient rather than above it. */
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
position: absolute;
inset: var(--nn-pad) var(--nn-pad) auto;
pointer-events: none;
}
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
/* The overlay floats, so it takes no room — this keeps the title clear of it
and still centred, by holding back as much at the bottom as at the top. */
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
padding: calc(var(--unit) * 1.4) 0;
}
/* Too short for the overlay: the title gets the whole panel to itself. */
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
display: none !important;
}
/* ── top row ── */ /* ── top row ── */
[data-module="propresenter-now-next"] .nn-top { [data-module="propresenter-now-next"] .nn-top {
@@ -104,7 +59,6 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live { [data-module="propresenter-now-next"] .nn-live {
display: flex; display: flex;
@@ -117,7 +71,6 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live-dot { [data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6); width: calc(var(--unit) * 0.6);
@@ -169,7 +122,6 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--accent); color: var(--accent);
} }
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-title-box { [data-module="propresenter-now-next"] .nn-title-box {
flex: 1; flex: 1;