diff --git a/README.md b/README.md index efcc262..fc02300 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,13 @@ below. Nothing is installed without someone clicking. Tile settings: channel icons, direction and timestamps. - **ProPresenter** — what's live and what's next, with slide progress, 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. + 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, 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" diff --git a/modules/propresenter-now-next/client.js b/modules/propresenter-now-next/client.js index 624cca9..f168a88 100644 --- a/modules/propresenter-now-next/client.js +++ b/modules/propresenter-now-next/client.js @@ -6,6 +6,12 @@ in /admin; the tile only renders. */ 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 feedOffline = false; let stream = null; @@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) { `; const screenEl = root.querySelector('.nn-screen'); + if (nowOnly) screenEl.classList.add('nn-only-now'); const topRow = root.querySelector('.nn-top'); const playlistNameEl = root.querySelector('.nn-playlist'); const liveEl = root.querySelector('.nn-live'); const liveTextEl = root.querySelector('.nn-live-text'); const nowEl = root.querySelector('.nn-now'); + const nowLabelEl = root.querySelector('.nn-label'); const nowTitleBox = root.querySelector('.nn-title-box'); let nowTitleEl = root.querySelector('.nn-title'); const nowMetaEl = root.querySelector('.nn-meta'); @@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) { const nextListEl = root.querySelector('.nn-next-list'); const nextMoreEl = root.querySelector('.nn-next-more'); + /** A setting, defaulting to on — a Now tile's schema omits the Next ones. */ function prefs() { - return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles + return moduleApi.instanceSettings; } + const on = (key) => prefs()[key] !== false; /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ @@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) { } function displayName(raw) { - return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim(); + return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim(); } function itemTypeTag(type) { @@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) { const total = state.currentSlideTotal || activeItem?.slideCount || 0; const current = state.currentSlide || 0; - if (total > 0 && current > 0) { + if (!on('showSlideCount')) { + nowMetaEl.textContent = ''; + } else if (total > 0 && current > 0) { const remaining = Math.max(0, total - current); nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; } else if (total > 0) { @@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) { nowMetaEl.textContent = ''; } - if (prefs().showProgress && total > 0) { + if (on('showProgress') && total > 0) { progressEl.hidden = false; const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; progressFillEl.style.width = `${pct}%`; @@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) { nameEl.textContent = displayName(item.name) || '(untitled)'; row.appendChild(nameEl); + if (!on('showTags')) { + nextListEl.appendChild(row); + continue; + } if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { const chip = document.createElement('div'); chip.className = 'nn-count-chip'; @@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) { nextListEl.appendChild(row); } - nextLabelEl.hidden = upcoming.length === 0; + nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel'); nextMoreEl.hidden = true; markNextOverflow(); requestAnimationFrame(markNextOverflow); @@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) { function render() { screenEl.classList.toggle('is-offline', feedOffline); - const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled; - topRow.classList.toggle('hidden-row', !showTop); - playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : ''; + // Trouble always shows, whatever is switched off: a tile that has stopped + // updating must never look like one with nothing live. + const trouble = feedOffline || !state?.reachable || !state?.enabled; + 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(); renderNow(); - renderNext(); + if (!nowOnly && on('showNext')) renderNext(); } /* ── sizing: --unit scales the whole card to the tile ───────────── */ @@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) { function applyUnit() { const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); 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 ──────────────────────────────────────────────────── */ diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index fe25d0d..67d188e 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -1,8 +1,8 @@ { "id": "propresenter-now-next", "name": "ProPresenter", - "version": "1.0.1", - "proddash": ">=1.1.0", + "version": "1.1.0", + "proddash": ">=1.5.0", "description": "What is live in ProPresenter and what comes next", "permissions": [ { "kind": "local-network", "reason": "Reaching ProPresenter on this network" } @@ -20,13 +20,22 @@ }, "password": { "type": "password", - "label": "API password (only if set in ProPresenter)", + "label": "Password", "default": "" } }, "instanceSchema": { - "showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true }, - "showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true }, - "cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true } + "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" }, + "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 } } } diff --git a/modules/propresenter-now-next/server.js b/modules/propresenter-now-next/server.js index 781cccb..e13503e 100644 --- a/modules/propresenter-now-next/server.js +++ b/modules/propresenter-now-next/server.js @@ -108,6 +108,43 @@ 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() { return { 'GET /state': (req, res) => { diff --git a/modules/propresenter-now-next/style.css b/modules/propresenter-now-next/style.css index 8ef2efc..3962d09 100644 --- a/modules/propresenter-now-next/style.css +++ b/modules/propresenter-now-next/style.css @@ -21,6 +21,51 @@ 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 ── */ [data-module="propresenter-now-next"] .nn-top { @@ -59,6 +104,7 @@ overflow: hidden; text-overflow: ellipsis; } +[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; } [data-module="propresenter-now-next"] .nn-live { display: flex; @@ -71,6 +117,7 @@ text-transform: uppercase; color: var(--muted); } +[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; } [data-module="propresenter-now-next"] .nn-live-dot { width: calc(var(--unit) * 0.6); @@ -122,6 +169,7 @@ text-transform: uppercase; color: var(--accent); } +[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; } [data-module="propresenter-now-next"] .nn-title-box { flex: 1;