Files
Claude fb4ee6fa62 Slideshow folders can include PDFs
PDFs in a slideshow folder now play alongside images and Word documents:
a one-page PDF is shown whole, a longer one fits the width and scrolls
through its pages during docInterval (first 30 pages - rendering a huge
PDF for a few seconds on screen isn't worth the time and memory).

- kiosk-app/pdf-render.mjs renders pages to canvases with pdf.js
  (pdfjs-dist ^6.3.289, new kiosk-app dependency): no viewer toolbar, and
  the slideshow's existing auto-scroll works on it. pdf.js >= 4.2.67 is
  required (CVE-2024-4367: older versions could run script embedded in a
  crafted PDF); isEvalSupported:false too. It's a module because pdf.js
  only ships as one - confirmed to load (with its worker) from file:// in
  Electron 44.
- Hands pdf.js a copy of the bytes: it transfers the buffer it's given,
  which would break the cached copy the next time round.
- slideshow.js lists .pdf as a document type; the title-request handler
  serves any listed non-image item (still only from that folder).
- provision_install_app now copies kiosk-app/*.mjs as well - without it
  pdf-render.mjs would never reach an installed kiosk.

Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox,
app files copied with provision_install_app's own glob: a photo, a
one-page PDF, a three-page PDF and a .docx played in order, twice round;
the one-page PDF shown whole, the three-page one scrolled through all
pages. Offline bundle rebuilt with pdfjs-dist (437 MB total, +22 MB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
2026-10-03 15:43:05 +00:00

234 lines
8.8 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Slideshow</title>
<!-- 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; 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>
<script type="module" src="pdf-render.mjs"></script>
</head>
<body>
<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();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function stopTimers() {
clearTimeout(timer);
cancelAnimationFrame(scrollAnim);
scrollAnim = null;
}
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 !== 'image');
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 (item.type === 'pdf') {
if (!window.KioskPdf) throw new Error('PDF renderer missing');
const bytes = await getDocBytes(item);
const box = document.createElement('div');
layer.appendChild(box);
await KioskPdf.render(bytes, box, window.innerWidth, window.innerHeight);
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);
};
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 isDoc = item.type !== 'image';
const seconds = isDoc ? state.options.docInterval : state.options.interval;
if (isDoc) autoScroll(back, seconds);
// A lone document still cycles, so its scroll starts over from the top.
if (order.length > 1 || isDoc) timer = setTimeout(next, seconds * 1000);
}
window.kioskSlideshow = {
update(s) {
const first = !state;
const sameItems = state && JSON.stringify(state.items) === JSON.stringify(s.items);
const currentKey = keyOf(order[pos]);
state = s;
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 = [];
generation++;
showMessage(s.exists ? `No images, documents or PDFs in ${s.dir}` : `Folder not found: ${s.dir}`);
return;
}
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);
const it = order[pos];
const seconds = it.type !== 'image' ? s.options.docInterval : s.options.interval;
if (order.length > 1 || it.type !== 'image') 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>
</body>
</html>