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
234 lines
8.8 KiB
HTML
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>
|