Slideshow folders can mix Word documents in with images

A slideshow folder now plays .docx files alongside images: a one-page
document is shown whole (letterboxed like a photo); a longer one fills
the width and scrolls smoothly top to bottom during its docInterval
(new slideshow.json key, default 2x interval). Word's ~$ lock files are
skipped; changed documents (by mtime) are re-read on the minute refresh.

- kiosk-app/docx-render.js: the docx-preview rendering now shared by
  docview.html and slideshow.html. Each render gets its own class prefix
  and keeps its styles in its own container, so two documents crossfading
  can't restyle each other. "One page" is judged by content fitting the
  paper height, not section count - docx-preview only starts a new
  section at explicit page breaks, so a long unbroken document is one
  tall section.
- Document bytes are fetched on demand: the page sets its title to a
  request, slideshow.js answers via executeJavaScript - only for a .docx
  in that slideshow's own current listing (no file-read API exposed to
  pages; the preload is shared by every site).

Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox on a
folder of 2 images + a 1-page and a long .docx (+ a ~$ lock file): order,
crossfades, whole-page fit, scroll to the end, lock file skipped; the
single-.docx site still renders. The request handler answered a listed
.docx and refused /etc/passwd, a listed image, and a .docx outside the
folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
Claude
2026-10-03 15:07:24 +00:00
parent c54ae352a1
commit 4b85ae59fc
5 changed files with 371 additions and 144 deletions
+12 -9
View File
@@ -197,14 +197,17 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c
instead of a web address and it's shown from the kiosk's own disk:
HTML pages, PDFs (built-in viewer), images, and Word documents (below). Works offline. The file
must be readable by the `kiosk` user.
- **Image slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`)
and its images (jpg, png, gif, webp, bmp, svg, avif) play full-screen in
file-name order with a crossfade, 10 seconds each. Images added to or
removed from the folder are picked up within a minute, no restart
needed. Optional `slideshow.json` in the folder:
`{"interval": 10, "shuffle": false, "fit": "contain", "transition": 1, "recursive": false}`
(`fit`: `contain` shows the whole image, `cover` fills the screen and
crops; `recursive` includes subfolders).
- **Slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`) and
its images (jpg, png, gif, webp, bmp, svg, avif) and Word documents
(`.docx`) play full-screen in file-name order with a crossfade - images
10 seconds each, documents 20. A one-page document is shown whole; a
longer one fills the width and scrolls slowly top to bottom during its
time. Files added, removed or changed are picked up within a minute, no
restart needed. Optional `slideshow.json` in the folder:
`{"interval": 10, "docInterval": 20, "shuffle": false, "fit": "contain", "transition": 1, "recursive": false}`
(`docInterval`: seconds per document; `fit`: `contain` shows the whole
image, `cover` fills the screen and crops; `recursive` includes
subfolders).
- **Word documents** - A `.docx` path (e.g. `/home/kiosk/docs/menu.docx`)
is rendered as pages (text, tables, images, lists), scaled to the screen
width, via the bundled [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
@@ -1474,7 +1477,7 @@ full migration pass.
- **All-in-one install ISO** (`iso/build-iso.sh`, or Actions → Build kiosk ISO): Ubuntu Server 26.04 plus the kiosk in one USB stick — the Ubuntu install, then `install.sh` automatically on first boot. Interactive, unattended, or fully automatic. See "All-in-One Install ISO" above.
- **Offline installs** (`--offline`): the ISO carries every package (including CUPS printing and all printer drivers), Node.js 22, and prebuilt Electron/Web UI modules, so a kiosk installs with no internet. With internet it still installs current versions online. Addons → CUPS Printing (menu or Web UI) installs from the bundle by itself when offline.
- **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI.
- **Word documents as sites:** a `.docx` path renders in the kiosk via docx-preview (~1 MB of JavaScript, bundled offline), updating when the file changes.
- **Word documents as sites:** a `.docx` path renders in the kiosk via docx-preview (~1 MB of JavaScript, bundled offline), updating when the file changes. Slideshow folders can mix `.docx` documents in with images.
- **Electron upgrade:** v42.x → v44.x (42 leaves Electron's support window when 45 ships). Existing kiosks move to it via Advanced → Upgrade.
- **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`).
+6 -54
View File
@@ -7,10 +7,6 @@
document's bytes arrive from main.js via window.kioskDocView.show(). -->
<style>
html, body { margin: 0; background: #666; }
#doc { transform-origin: top left; }
/* docx-preview's page wrapper: grey backdrop with white pages. */
#doc .docx-wrapper { background: #666; padding: 16px 0; }
#doc .docx-wrapper > section.docx { margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,.4); }
#msg {
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
color: #eee; font: 22px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
@@ -18,6 +14,7 @@
</style>
<script src="node_modules/jszip/dist/jszip.min.js"></script>
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
<script src="docx-render.js"></script>
</head>
<body>
<div id="msg">Loading document…</div>
@@ -26,6 +23,7 @@
(() => {
const docEl = document.getElementById('doc');
const msg = document.getElementById('msg');
let cls = null;
function showMessage(text) {
msg.textContent = text;
@@ -33,65 +31,19 @@
docEl.innerHTML = '';
}
// Scale pages to the screen width (never above 100% + a little), so a
// portrait page is readable on a landscape kiosk without sideways
// scrolling. CSS zoom keeps scrolling and touch coordinates consistent.
function fitWidth() {
const page = docEl.querySelector('section.docx');
if (!page) return;
docEl.style.zoom = 1;
const pageWidth = page.getBoundingClientRect().width + 32;
docEl.style.zoom = Math.min(window.innerWidth / pageWidth, 1.6);
}
window.addEventListener('resize', fitWidth);
// Word draws most bullets with private-use characters from the Symbol
// and Wingdings fonts (U+F0B7 etc.), which a kiosk doesn't have - they
// render as blanks. docx-preview passes them through into its generated
// list CSS; swap the common ones for real Unicode equivalents.
const SYMBOL_BULLETS = {
'\uf0b7': '\u2022', // Symbol bullet -> •
'\uf0a7': '\u25aa', // Wingdings square -> ▪
'\uf0d8': '\u27a2', // Wingdings arrowhead -> ➢
'\uf076': '\u2756', // Wingdings diamond -> ❖
'\uf0fc': '\u2713', // Wingdings check -> ✓
'\uf06e': '\u25a0', // Wingdings black square -> ■
'\uf071': '\u274f', // Wingdings box -> ❏
};
function fixSymbolBullets() {
document.querySelectorAll('style').forEach((st) => {
let css = st.textContent;
const before = css;
for (const [from, to] of Object.entries(SYMBOL_BULLETS)) {
css = css.split(from).join(to);
// Also the CSS-escaped form ("\f0b7" / "\00f0b7 "), in case it's
// emitted that way.
const hex = from.charCodeAt(0).toString(16);
css = css.replace(new RegExp('\\\\0*' + hex + '\\s?', 'gi'), to);
}
if (css !== before) st.textContent = css;
});
}
window.addEventListener('resize', () => cls && KioskDocx.fit(docEl, cls, window.innerWidth));
window.kioskDocView = {
async show(d) {
if (d.error) { showMessage(d.error); return; }
if (!window.docx || !window.JSZip) {
if (!window.KioskDocx || !KioskDocx.available()) {
showMessage('Word document viewer is missing (docx-preview) - run Advanced -> Upgrade.');
return;
}
try {
const bytes = Uint8Array.from(atob(d.data), (c) => c.charCodeAt(0));
docEl.innerHTML = '';
await window.docx.renderAsync(bytes, docEl, null, {
inWrapper: true,
ignoreLastRenderedPageBreak: true,
experimental: true,
useBase64URL: true,
});
cls = await KioskDocx.render(KioskDocx.base64ToBytes(d.data), docEl);
msg.style.display = 'none';
fixSymbolBullets();
fitWidth();
KioskDocx.fit(docEl, cls, window.innerWidth);
// Only called on first load or when the file changed on disk -
// start a changed document from the top.
window.scrollTo(0, 0);
+110
View File
@@ -0,0 +1,110 @@
// Browser-side .docx rendering shared by docview.html (one document as a
// site) and slideshow.html (documents mixed into a folder slideshow).
// Needs node_modules/jszip and node_modules/docx-preview loaded first.
//
// Each render gets its own class prefix and keeps its generated styles
// inside its own container, so two documents on screen at once (a
// slideshow crossfade) can't restyle each other, and removing the
// container removes its styles.
(() => {
let counter = 0;
// Word draws most bullets with private-use characters from the Symbol
// and Wingdings fonts (U+F0B7 etc.), which a kiosk doesn't have - they
// render as blanks. docx-preview passes them through into its generated
// list CSS; swap the common ones for real Unicode equivalents.
const SYMBOL_BULLETS = {
'': '•', // Symbol bullet -> •
'': '▪', // Wingdings square -> ▪
'': '➢', // Wingdings arrowhead -> ➢
'': '❖', // Wingdings diamond -> ❖
'': '✓', // Wingdings check -> ✓
'': '■', // Wingdings black square -> ■
'': '❏', // Wingdings box -> ❏
};
function fixSymbolBullets(container) {
container.querySelectorAll('style').forEach((st) => {
let css = st.textContent;
const before = css;
for (const [from, to] of Object.entries(SYMBOL_BULLETS)) {
css = css.split(from).join(to);
// Also the CSS-escaped form ("\f0b7" / "\00f0b7 ").
const hex = from.charCodeAt(0).toString(16);
css = css.replace(new RegExp('\\\\0*' + hex + '\\s?', 'gi'), to);
}
if (css !== before) st.textContent = css;
});
}
window.KioskDocx = {
available() {
return !!(window.docx && window.JSZip);
},
base64ToBytes(b64) {
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
},
// Render into `container` (emptied first). Returns the class prefix
// used, for fit().
async render(bytes, container) {
const cls = `kdocx${++counter}`;
container.innerHTML = '';
await window.docx.renderAsync(bytes, container, container, {
className: cls,
inWrapper: true,
ignoreLastRenderedPageBreak: true,
experimental: true,
useBase64URL: true,
});
const wrapper = container.querySelector(`.${cls}-wrapper`);
if (wrapper) {
wrapper.style.background = 'transparent';
wrapper.style.padding = '16px 0';
}
container.querySelectorAll(`section.${cls}`).forEach((s) => {
s.style.marginBottom = '16px';
s.style.boxShadow = '0 2px 8px rgba(0,0,0,.4)';
});
fixSymbolBullets(container);
return cls;
},
// Scale `zoomEl` so the first page fills `width` (capped at maxZoom),
// so a portrait page is readable on a landscape screen without
// sideways scrolling. CSS zoom keeps scrolling and touch coordinates
// consistent.
fit(zoomEl, cls, width, maxZoom = 1.6) {
const page = zoomEl.querySelector(`section.${cls}`);
if (!page) return;
zoomEl.style.zoom = 1;
const pageWidth = page.getBoundingClientRect().width + 32;
zoomEl.style.zoom = Math.min(width / pageWidth, maxZoom);
},
// Scale `zoomEl` so the whole first page fits in width x height
// (letterboxed, like an image) - for one-page documents in a slideshow,
// where scrolling through the page's empty bottom would be pointless.
fitPage(zoomEl, cls, width, height) {
const page = zoomEl.querySelector(`section.${cls}`);
if (!page) return;
zoomEl.style.zoom = 1;
const r = page.getBoundingClientRect();
zoomEl.style.zoom = Math.min(width / (r.width + 32), height / (r.height + 32));
},
// True when the whole document fits on one physical page. Not just
// "one <section>": docx-preview doesn't paginate by itself - it only
// starts a new section at an explicit page break - so a long document
// without breaks is one very tall section. Each section's min-height
// is the paper height, so content that fits leaves it at that height.
isSinglePage(zoomEl, cls) {
const pages = zoomEl.querySelectorAll(`section.${cls}`);
if (pages.length !== 1) return false;
const paper = parseFloat(getComputedStyle(pages[0]).minHeight);
if (!paper) return false;
return pages[0].getBoundingClientRect().height <= paper * 1.02;
},
};
})();
+161 -42
View File
@@ -3,34 +3,49 @@
<head>
<meta charset="utf-8">
<title>Slideshow</title>
<!-- Shown for a site that's a local folder - see slideshow.js. The image
list arrives from main.js via window.kioskSlideshow.update(). -->
<!-- Shown for a site that's a local folder - see slideshow.js. The item
list arrives from main.js via window.kioskSlideshow.update(); a Word
document's bytes are requested by setting the page title (see
TITLE_REQUEST in slideshow.js) and arrive via kioskSlideshow.docData(). -->
<style>
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: none; }
.slide {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: contain; opacity: 0; transition-property: opacity;
position: absolute; inset: 0; overflow: hidden;
opacity: 0; transition-property: opacity;
}
.slide.show { opacity: 1; }
.slide img { width: 100%; height: 100%; object-fit: contain; display: block; }
.slide.doc { background: #666; }
#msg {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #888; font: 24px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
}
</style>
<script src="node_modules/jszip/dist/jszip.min.js"></script>
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
<script src="docx-render.js"></script>
</head>
<body>
<img class="slide" id="a" alt="">
<img class="slide" id="b" alt="">
<div class="slide" id="a"></div>
<div class="slide" id="b"></div>
<div id="msg">Loading slideshow…</div>
<script>
(() => {
const TITLE = 'Slideshow';
const TITLE_REQUEST = 'kiosk-slideshow-need:';
const layers = [document.getElementById('a'), document.getElementById('b')];
const msg = document.getElementById('msg');
let front = 0;
let order = [];
let pos = -1;
let timer = null;
let scrollAnim = null;
let state = null;
let generation = 0; // bumps on every (re)start, cancels stale work
const docCache = new Map(); // url -> { mtime, bytes }
const pendingDocs = new Map(); // url -> { resolve, reject, timeout }
const keyOf = (it) => it && `${it.type}:${it.url}:${it.mtime || ''}`;
function shuffled(list) {
const a = list.slice();
@@ -41,62 +56,166 @@
return a;
}
function showMessage(text) {
msg.textContent = text;
msg.style.display = 'flex';
layers.forEach((l) => { l.classList.remove('show'); l.removeAttribute('src'); });
function stopTimers() {
clearTimeout(timer);
cancelAnimationFrame(scrollAnim);
scrollAnim = null;
}
// Load the next image into the hidden layer, then crossfade to it.
// Unreadable/corrupt images are skipped rather than stalling the show.
function next(attempts = 0) {
clearTimeout(timer);
if (!order.length) return;
if (attempts >= order.length) { showMessage('None of the images in this folder could be shown.'); return; }
pos = (pos + 1) % order.length;
if (pos === 0 && state.options.shuffle && order.length > 1) order = shuffled(state.images);
const back = layers[1 - front];
back.onload = () => {
msg.style.display = 'none';
back.classList.add('show');
layers[front].classList.remove('show');
front = 1 - front;
if (order.length > 1) timer = setTimeout(next, state.options.interval * 1000);
function showMessage(text) {
stopTimers();
msg.textContent = text;
msg.style.display = 'flex';
layers.forEach((l) => { l.classList.remove('show'); l.innerHTML = ''; });
}
// Ask main.js for a document's bytes (see slideshow.js); cached by mtime
// so a document is only re-read when it changed on disk.
function getDocBytes(item) {
const hit = docCache.get(item.url);
if (hit && hit.mtime === item.mtime) return Promise.resolve(hit.bytes);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingDocs.delete(item.url);
document.title = TITLE;
reject(new Error('timed out reading the file'));
}, 20000);
pendingDocs.set(item.url, { resolve, reject, timeout, mtime: item.mtime });
// main.js notices title *changes*; every answer (or timeout) puts it
// back to TITLE, so each request is a change.
document.title = TITLE_REQUEST + item.url;
});
}
// Fill `layer` with `item`, resolving once it's ready to fade in.
async function prepare(layer, item) {
layer.innerHTML = '';
layer.scrollTop = 0;
layer.classList.toggle('doc', item.type === 'docx');
if (item.type === 'image') {
const img = new Image();
img.style.objectFit = state.options.fit;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = () => reject(new Error('unreadable image'));
img.src = item.url;
});
layer.appendChild(img);
return;
}
if (!KioskDocx.available()) throw new Error('Word document viewer missing');
const bytes = await getDocBytes(item);
const zoomEl = document.createElement('div');
layer.appendChild(zoomEl);
const cls = await KioskDocx.render(bytes, zoomEl);
// One page: show all of it at once. Longer: fit the width and let
// autoScroll() move through it.
if (KioskDocx.isSinglePage(zoomEl, cls)) {
KioskDocx.fitPage(zoomEl, cls, window.innerWidth, window.innerHeight);
zoomEl.style.display = 'flex';
zoomEl.style.justifyContent = 'center';
} else {
KioskDocx.fit(zoomEl, cls, window.innerWidth);
}
}
// A document taller than the screen scrolls smoothly top to bottom
// during its time on screen: holds at the top for the first 15%,
// reaches the bottom at 85%, then holds.
function autoScroll(layer, seconds) {
const distance = layer.scrollHeight - layer.clientHeight;
if (distance <= 4) return;
const start = performance.now();
const total = seconds * 1000;
const step = (now) => {
const t = Math.min(Math.max(((now - start) / total - 0.15) / 0.7, 0), 1);
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
layer.scrollTop = distance * eased;
if (t < 1) scrollAnim = requestAnimationFrame(step);
};
back.onerror = () => next(attempts + 1);
back.src = order[pos];
scrollAnim = requestAnimationFrame(step);
}
async function next(attempts = 0) {
stopTimers();
if (!order.length) return;
if (attempts >= order.length) { showMessage('None of the files in this folder could be shown.'); return; }
const gen = generation;
pos = (pos + 1) % order.length;
if (pos === 0 && state.options.shuffle && order.length > 1) order = shuffled(order);
const item = order[pos];
const back = layers[1 - front];
try {
await prepare(back, item);
} catch (e) {
console.warn('[SLIDESHOW] Skipping', item.url, '-', e.message || e);
if (gen === generation) next(attempts + 1);
return;
}
if (gen !== generation) return; // folder changed while loading
msg.style.display = 'none';
const old = layers[front];
back.classList.add('show');
old.classList.remove('show');
front = 1 - front;
// Free the old slide (a rendered document can be large) once faded out.
setTimeout(() => { if (!old.classList.contains('show')) old.innerHTML = ''; },
state.options.transition * 1000 + 100);
const seconds = item.type === 'docx' ? state.options.docInterval : state.options.interval;
if (item.type === 'docx') autoScroll(back, seconds);
// A lone document still cycles, so its scroll starts over from the top.
if (order.length > 1 || item.type === 'docx') timer = setTimeout(next, seconds * 1000);
}
window.kioskSlideshow = {
update(s) {
const first = !state;
const sameImages = state && JSON.stringify(state.images) === JSON.stringify(s.images);
const current = order[pos];
const sameItems = state && JSON.stringify(state.items) === JSON.stringify(s.items);
const currentKey = keyOf(order[pos]);
state = s;
layers.forEach((l) => {
l.style.objectFit = s.options.fit;
l.style.transitionDuration = s.options.transition + 's';
});
if (!s.images.length) {
layers.forEach((l) => { l.style.transitionDuration = s.options.transition + 's'; });
layers.forEach((l) => l.querySelectorAll('img').forEach((img) => { img.style.objectFit = s.options.fit; }));
// Drop cached documents that left the folder.
for (const url of docCache.keys()) {
if (!s.items.some((it) => it.url === url)) docCache.delete(url);
}
if (!s.items.length) {
order = [];
clearTimeout(timer);
showMessage(s.exists ? `No images in ${s.dir}` : `Folder not found: ${s.dir}`);
generation++;
showMessage(s.exists ? `No images or documents in ${s.dir}` : `Folder not found: ${s.dir}`);
return;
}
if (sameImages && !first) return;
// Folder changed: keep showing the current picture if it's still
// there and carry on from it, otherwise restart from the top.
order = s.options.shuffle ? shuffled(s.images) : s.images.slice();
const keep = current ? order.indexOf(current) : -1;
if (sameItems && !first) return;
// Folder changed: keep showing the current item if it's still there
// unchanged and carry on from it, otherwise restart from the top.
generation++;
order = s.options.shuffle ? shuffled(s.items) : s.items.slice();
const keep = currentKey ? order.findIndex((it) => keyOf(it) === currentKey) : -1;
if (keep >= 0 && !first) {
pos = keep;
clearTimeout(timer);
if (order.length > 1) timer = setTimeout(next, s.options.interval * 1000);
const it = order[pos];
const seconds = it.type === 'docx' ? s.options.docInterval : s.options.interval;
if (order.length > 1 || it.type === 'docx') timer = setTimeout(next, seconds * 1000);
} else {
pos = -1;
next();
}
},
docData(url, d) {
const p = pendingDocs.get(url);
if (!p) return;
pendingDocs.delete(url);
clearTimeout(p.timeout);
document.title = TITLE;
if (d.error) { p.reject(new Error(d.error)); return; }
const bytes = KioskDocx.base64ToBytes(d.data);
docCache.set(url, { mtime: p.mtime, bytes });
// Keep at most 10 documents' bytes around.
while (docCache.size > 10) docCache.delete(docCache.keys().next().value);
p.resolve(bytes);
},
};
})();
</script>
+82 -39
View File
@@ -2,20 +2,29 @@
//
// A site whose URL is a local folder (file:///home/kiosk/photos, entered
// in the Sites menu or Web UI as /home/kiosk/photos/) is shown as a
// full-screen slideshow of the images in it, instead of Chromium's
// directory listing. main.js swaps the folder URL for slideshow.html via
// resolveSiteUrl(), then hands the page its image list with attach() -
// the page itself has no filesystem access (contextIsolation, no Node).
// The folder is re-read every minute, so images added or removed show up
// without restarting the kiosk.
// full-screen slideshow of the images and Word documents (.docx) in it,
// instead of Chromium's directory listing. main.js swaps the folder URL
// for slideshow.html via resolveSiteUrl(), then attach() hands the page
// its item list - the page itself has no filesystem access
// (contextIsolation, no Node). The folder is re-read every minute, so
// files added, removed or changed show up without restarting the kiosk.
//
// Images load directly by file:// URL. A document's bytes are fetched on
// demand: the page sets its title to TITLE_REQUEST + the document's URL,
// and attach() answers via kioskSlideshow.docData() - but only for a
// .docx in that slideshow's own current item list, so a page can't use
// this to read anything else on disk.
//
// Optional slideshow.json in the folder (all keys optional):
// { "interval": 10, seconds per image (default 10, minimum 2)
// "docInterval": 20, seconds per document (default 2x interval,
// minimum 5); a document longer than the
// screen scrolls slowly top to bottom in it
// "shuffle": false, random order instead of by file name
// "fit": "contain", "contain" (whole image, letterboxed) or
// "cover" (fill the screen, cropped)
// "fit": "contain", images: "contain" (whole image, letterboxed)
// or "cover" (fill the screen, cropped)
// "transition": 1, crossfade seconds (0 = instant cut)
// "recursive": false } include images in subfolders
// "recursive": false } include files in subfolders
const fs = require('fs');
const path = require('path');
@@ -23,13 +32,16 @@ const { fileURLToPath, pathToFileURL } = require('url');
const SLIDESHOW_PAGE = path.join(__dirname, 'slideshow.html');
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.avif']);
const DOC_EXT = new Set(['.docx']);
const REFRESH_MS = 60000;
const DEFAULTS = { interval: 10, shuffle: false, fit: 'contain', transition: 1, recursive: false };
const MAX_DOC_BYTES = 50 * 1024 * 1024;
const TITLE_REQUEST = 'kiosk-slideshow-need:';
const DEFAULTS = { interval: 10, docInterval: null, shuffle: false, fit: 'contain', transition: 1, recursive: false };
// The folder path a site URL points at, or null if it isn't a slideshow.
// A file:// URL counts when it's an existing directory, or ends in "/"
// (so a folder that isn't there yet - e.g. a USB stick not plugged in -
// still shows the slideshow's "no images" message, not an error page).
// still shows the slideshow's "nothing to show" message, not an error).
function slideshowDir(url) {
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
let p;
@@ -57,20 +69,22 @@ function resolveSiteUrl(url) {
function readOptions(dir) {
const opts = { ...DEFAULTS };
let user = {};
try {
const user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8'));
if (Number(user.interval) > 0) opts.interval = Math.max(2, Number(user.interval));
if (typeof user.shuffle === 'boolean') opts.shuffle = user.shuffle;
if (user.fit === 'contain' || user.fit === 'cover') opts.fit = user.fit;
if (Number(user.transition) >= 0) opts.transition = Math.min(Number(user.transition), opts.interval / 2);
if (typeof user.recursive === 'boolean') opts.recursive = user.recursive;
user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8'));
} catch (e) {
// No (or unreadable) slideshow.json - defaults.
}
if (Number(user.interval) > 0) opts.interval = Math.max(2, Number(user.interval));
opts.docInterval = Number(user.docInterval) > 0 ? Math.max(5, Number(user.docInterval)) : opts.interval * 2;
if (typeof user.shuffle === 'boolean') opts.shuffle = user.shuffle;
if (user.fit === 'contain' || user.fit === 'cover') opts.fit = user.fit;
if (Number(user.transition) >= 0) opts.transition = Math.min(Number(user.transition), opts.interval / 2);
if (typeof user.recursive === 'boolean') opts.recursive = user.recursive;
return opts;
}
function listImages(dir, recursive, depth = 0) {
function listItems(dir, recursive, depth = 0) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -79,12 +93,18 @@ function listImages(dir, recursive, depth = 0) {
}
const out = [];
for (const e of entries) {
if (e.name.startsWith('.')) continue;
// Skip hidden files and Word's "~$name.docx" lock files.
if (e.name.startsWith('.') || e.name.startsWith('~$')) continue;
const full = path.join(dir, e.name);
const ext = path.extname(e.name).toLowerCase();
if (e.isDirectory()) {
if (recursive && depth < 8) out.push(...listImages(full, recursive, depth + 1));
} else if (IMAGE_EXT.has(path.extname(e.name).toLowerCase())) {
out.push(full);
if (recursive && depth < 8) out.push(...listItems(full, recursive, depth + 1));
} else if (IMAGE_EXT.has(ext)) {
out.push({ path: full, type: 'image' });
} else if (DOC_EXT.has(ext)) {
let mtime = 0;
try { mtime = fs.statSync(full).mtimeMs; } catch (err) { /* listed but gone */ }
out.push({ path: full, type: 'docx', mtime });
}
}
return out;
@@ -92,36 +112,59 @@ function listImages(dir, recursive, depth = 0) {
function snapshot(dir) {
const options = readOptions(dir);
const files = listImages(dir, options.recursive)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
return {
dir,
exists: fs.existsSync(dir),
options,
images: files.map((f) => pathToFileURL(f).href),
};
const items = listItems(dir, options.recursive)
.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }))
.map((it) => ({ url: pathToFileURL(it.path).href, type: it.type, ...(it.mtime ? { mtime: it.mtime } : {}) }));
return { dir, exists: fs.existsSync(dir), options, items };
}
// Feed a view's slideshow page its image list now and every REFRESH_MS
// while it stays on a slideshow. Call once per view (main.js does, right
// after creating it); safe on views that never show a slideshow.
function readDoc(fileUrl) {
try {
const file = fileURLToPath(fileUrl);
const st = fs.statSync(file);
if (st.size > MAX_DOC_BYTES) return { error: `too large (${Math.round(st.size / 1048576)} MB)` };
return { mtime: st.mtimeMs, data: fs.readFileSync(file).toString('base64') };
} catch (e) {
return { error: e.code === 'ENOENT' ? 'file not found' : e.message };
}
}
// Feed a view's slideshow page its item list now and every REFRESH_MS
// while it stays on a slideshow, and answer its document requests. Call
// once per view (main.js does, right after creating it); safe on views
// that never show a slideshow.
function attach(webContents) {
const pagePrefix = pathToFileURL(SLIDESHOW_PAGE).href;
let lastSent = '';
let current = null; // latest snapshot sent to the page
const onSlideshow = () => !webContents.isDestroyed() && webContents.getURL().startsWith(pagePrefix);
const send = (force) => {
if (webContents.isDestroyed()) return;
const current = webContents.getURL();
if (!current.startsWith(pathToFileURL(SLIDESHOW_PAGE).href)) return;
const dir = new URL(current).searchParams.get('dir');
if (!onSlideshow()) return;
const dir = new URL(webContents.getURL()).searchParams.get('dir');
if (!dir) return;
const payload = JSON.stringify(snapshot(dir));
const snap = snapshot(dir);
const payload = JSON.stringify(snap);
if (!force && payload === lastSent) return;
lastSent = payload;
current = snap;
webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`)
.catch(() => {});
};
webContents.on('did-finish-load', () => send(true));
webContents.on('page-title-updated', (event, title) => {
if (!title.startsWith(TITLE_REQUEST) || !onSlideshow() || !current) return;
const url = title.slice(TITLE_REQUEST.length);
if (!current.items.some((it) => it.type === 'docx' && it.url === url)) return;
const doc = readDoc(url);
webContents.executeJavaScript(
`window.kioskSlideshow&&window.kioskSlideshow.docData(${JSON.stringify(url)},${JSON.stringify(doc)})`,
).catch(() => {});
});
const timer = setInterval(() => send(false), REFRESH_MS);
webContents.once('destroyed', () => clearInterval(timer));
}
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot };
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot, TITLE_REQUEST };