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:
@@ -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:
|
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
|
HTML pages, PDFs (built-in viewer), images, and Word documents (below). Works offline. The file
|
||||||
must be readable by the `kiosk` user.
|
must be readable by the `kiosk` user.
|
||||||
- **Image slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`)
|
- **Slideshow** - Enter a folder instead (e.g. `/home/kiosk/photos/`) and
|
||||||
and its images (jpg, png, gif, webp, bmp, svg, avif) play full-screen in
|
its images (jpg, png, gif, webp, bmp, svg, avif) and Word documents
|
||||||
file-name order with a crossfade, 10 seconds each. Images added to or
|
(`.docx`) play full-screen in file-name order with a crossfade - images
|
||||||
removed from the folder are picked up within a minute, no restart
|
10 seconds each, documents 20. A one-page document is shown whole; a
|
||||||
needed. Optional `slideshow.json` in the folder:
|
longer one fills the width and scrolls slowly top to bottom during its
|
||||||
`{"interval": 10, "shuffle": false, "fit": "contain", "transition": 1, "recursive": false}`
|
time. Files added, removed or changed are picked up within a minute, no
|
||||||
(`fit`: `contain` shows the whole image, `cover` fills the screen and
|
restart needed. Optional `slideshow.json` in the folder:
|
||||||
crops; `recursive` includes subfolders).
|
`{"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`)
|
- **Word documents** - A `.docx` path (e.g. `/home/kiosk/docs/menu.docx`)
|
||||||
is rendered as pages (text, tables, images, lists), scaled to the screen
|
is rendered as pages (text, tables, images, lists), scaled to the screen
|
||||||
width, via the bundled [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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`).
|
- **Fix:** the CUPS menu could report an installed CUPS as "not installed" (`dpkg -l | grep -q` failing under `set -o pipefail`).
|
||||||
|
|
||||||
|
|||||||
+6
-54
@@ -7,10 +7,6 @@
|
|||||||
document's bytes arrive from main.js via window.kioskDocView.show(). -->
|
document's bytes arrive from main.js via window.kioskDocView.show(). -->
|
||||||
<style>
|
<style>
|
||||||
html, body { margin: 0; background: #666; }
|
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 {
|
#msg {
|
||||||
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
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;
|
color: #eee; font: 22px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||||
@@ -18,6 +14,7 @@
|
|||||||
</style>
|
</style>
|
||||||
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
<script src="node_modules/jszip/dist/jszip.min.js"></script>
|
||||||
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
<script src="node_modules/docx-preview/dist/docx-preview.min.js"></script>
|
||||||
|
<script src="docx-render.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="msg">Loading document…</div>
|
<div id="msg">Loading document…</div>
|
||||||
@@ -26,6 +23,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const docEl = document.getElementById('doc');
|
const docEl = document.getElementById('doc');
|
||||||
const msg = document.getElementById('msg');
|
const msg = document.getElementById('msg');
|
||||||
|
let cls = null;
|
||||||
|
|
||||||
function showMessage(text) {
|
function showMessage(text) {
|
||||||
msg.textContent = text;
|
msg.textContent = text;
|
||||||
@@ -33,65 +31,19 @@
|
|||||||
docEl.innerHTML = '';
|
docEl.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scale pages to the screen width (never above 100% + a little), so a
|
window.addEventListener('resize', () => cls && KioskDocx.fit(docEl, cls, window.innerWidth));
|
||||||
// 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.kioskDocView = {
|
window.kioskDocView = {
|
||||||
async show(d) {
|
async show(d) {
|
||||||
if (d.error) { showMessage(d.error); return; }
|
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.');
|
showMessage('Word document viewer is missing (docx-preview) - run Advanced -> Upgrade.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const bytes = Uint8Array.from(atob(d.data), (c) => c.charCodeAt(0));
|
cls = await KioskDocx.render(KioskDocx.base64ToBytes(d.data), docEl);
|
||||||
docEl.innerHTML = '';
|
|
||||||
await window.docx.renderAsync(bytes, docEl, null, {
|
|
||||||
inWrapper: true,
|
|
||||||
ignoreLastRenderedPageBreak: true,
|
|
||||||
experimental: true,
|
|
||||||
useBase64URL: true,
|
|
||||||
});
|
|
||||||
msg.style.display = 'none';
|
msg.style.display = 'none';
|
||||||
fixSymbolBullets();
|
KioskDocx.fit(docEl, cls, window.innerWidth);
|
||||||
fitWidth();
|
|
||||||
// Only called on first load or when the file changed on disk -
|
// Only called on first load or when the file changed on disk -
|
||||||
// start a changed document from the top.
|
// start a changed document from the top.
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|||||||
@@ -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
@@ -3,34 +3,49 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Slideshow</title>
|
<title>Slideshow</title>
|
||||||
<!-- Shown for a site that's a local folder - see slideshow.js. The image
|
<!-- Shown for a site that's a local folder - see slideshow.js. The item
|
||||||
list arrives from main.js via window.kioskSlideshow.update(). -->
|
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>
|
<style>
|
||||||
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: none; }
|
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: none; }
|
||||||
.slide {
|
.slide {
|
||||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
position: absolute; inset: 0; overflow: hidden;
|
||||||
object-fit: contain; opacity: 0; transition-property: opacity;
|
opacity: 0; transition-property: opacity;
|
||||||
}
|
}
|
||||||
.slide.show { opacity: 1; }
|
.slide.show { opacity: 1; }
|
||||||
|
.slide img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||||
|
.slide.doc { background: #666; }
|
||||||
#msg {
|
#msg {
|
||||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
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;
|
color: #888; font: 24px sans-serif; text-align: center; padding: 2em; box-sizing: border-box;
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<img class="slide" id="a" alt="">
|
<div class="slide" id="a"></div>
|
||||||
<img class="slide" id="b" alt="">
|
<div class="slide" id="b"></div>
|
||||||
<div id="msg">Loading slideshow…</div>
|
<div id="msg">Loading slideshow…</div>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
|
const TITLE = 'Slideshow';
|
||||||
|
const TITLE_REQUEST = 'kiosk-slideshow-need:';
|
||||||
const layers = [document.getElementById('a'), document.getElementById('b')];
|
const layers = [document.getElementById('a'), document.getElementById('b')];
|
||||||
const msg = document.getElementById('msg');
|
const msg = document.getElementById('msg');
|
||||||
let front = 0;
|
let front = 0;
|
||||||
let order = [];
|
let order = [];
|
||||||
let pos = -1;
|
let pos = -1;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
let scrollAnim = null;
|
||||||
let state = 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) {
|
function shuffled(list) {
|
||||||
const a = list.slice();
|
const a = list.slice();
|
||||||
@@ -41,62 +56,166 @@
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(text) {
|
function stopTimers() {
|
||||||
msg.textContent = text;
|
clearTimeout(timer);
|
||||||
msg.style.display = 'flex';
|
cancelAnimationFrame(scrollAnim);
|
||||||
layers.forEach((l) => { l.classList.remove('show'); l.removeAttribute('src'); });
|
scrollAnim = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the next image into the hidden layer, then crossfade to it.
|
function showMessage(text) {
|
||||||
// Unreadable/corrupt images are skipped rather than stalling the show.
|
stopTimers();
|
||||||
function next(attempts = 0) {
|
msg.textContent = text;
|
||||||
clearTimeout(timer);
|
msg.style.display = 'flex';
|
||||||
if (!order.length) return;
|
layers.forEach((l) => { l.classList.remove('show'); l.innerHTML = ''; });
|
||||||
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);
|
// Ask main.js for a document's bytes (see slideshow.js); cached by mtime
|
||||||
const back = layers[1 - front];
|
// so a document is only re-read when it changed on disk.
|
||||||
back.onload = () => {
|
function getDocBytes(item) {
|
||||||
msg.style.display = 'none';
|
const hit = docCache.get(item.url);
|
||||||
back.classList.add('show');
|
if (hit && hit.mtime === item.mtime) return Promise.resolve(hit.bytes);
|
||||||
layers[front].classList.remove('show');
|
return new Promise((resolve, reject) => {
|
||||||
front = 1 - front;
|
const timeout = setTimeout(() => {
|
||||||
if (order.length > 1) timer = setTimeout(next, state.options.interval * 1000);
|
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);
|
scrollAnim = requestAnimationFrame(step);
|
||||||
back.src = order[pos];
|
}
|
||||||
|
|
||||||
|
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 = {
|
window.kioskSlideshow = {
|
||||||
update(s) {
|
update(s) {
|
||||||
const first = !state;
|
const first = !state;
|
||||||
const sameImages = state && JSON.stringify(state.images) === JSON.stringify(s.images);
|
const sameItems = state && JSON.stringify(state.items) === JSON.stringify(s.items);
|
||||||
const current = order[pos];
|
const currentKey = keyOf(order[pos]);
|
||||||
state = s;
|
state = s;
|
||||||
layers.forEach((l) => {
|
layers.forEach((l) => { l.style.transitionDuration = s.options.transition + 's'; });
|
||||||
l.style.objectFit = s.options.fit;
|
layers.forEach((l) => l.querySelectorAll('img').forEach((img) => { img.style.objectFit = s.options.fit; }));
|
||||||
l.style.transitionDuration = s.options.transition + 's';
|
// Drop cached documents that left the folder.
|
||||||
});
|
for (const url of docCache.keys()) {
|
||||||
if (!s.images.length) {
|
if (!s.items.some((it) => it.url === url)) docCache.delete(url);
|
||||||
|
}
|
||||||
|
if (!s.items.length) {
|
||||||
order = [];
|
order = [];
|
||||||
clearTimeout(timer);
|
generation++;
|
||||||
showMessage(s.exists ? `No images in ${s.dir}` : `Folder not found: ${s.dir}`);
|
showMessage(s.exists ? `No images or documents in ${s.dir}` : `Folder not found: ${s.dir}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sameImages && !first) return;
|
if (sameItems && !first) return;
|
||||||
// Folder changed: keep showing the current picture if it's still
|
// Folder changed: keep showing the current item if it's still there
|
||||||
// there and carry on from it, otherwise restart from the top.
|
// unchanged and carry on from it, otherwise restart from the top.
|
||||||
order = s.options.shuffle ? shuffled(s.images) : s.images.slice();
|
generation++;
|
||||||
const keep = current ? order.indexOf(current) : -1;
|
order = s.options.shuffle ? shuffled(s.items) : s.items.slice();
|
||||||
|
const keep = currentKey ? order.findIndex((it) => keyOf(it) === currentKey) : -1;
|
||||||
if (keep >= 0 && !first) {
|
if (keep >= 0 && !first) {
|
||||||
pos = keep;
|
pos = keep;
|
||||||
clearTimeout(timer);
|
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 {
|
} else {
|
||||||
pos = -1;
|
pos = -1;
|
||||||
next();
|
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>
|
</script>
|
||||||
|
|||||||
+82
-39
@@ -2,20 +2,29 @@
|
|||||||
//
|
//
|
||||||
// A site whose URL is a local folder (file:///home/kiosk/photos, entered
|
// 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
|
// 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
|
// full-screen slideshow of the images and Word documents (.docx) in it,
|
||||||
// directory listing. main.js swaps the folder URL for slideshow.html via
|
// instead of Chromium's directory listing. main.js swaps the folder URL
|
||||||
// resolveSiteUrl(), then hands the page its image list with attach() -
|
// for slideshow.html via resolveSiteUrl(), then attach() hands the page
|
||||||
// the page itself has no filesystem access (contextIsolation, no Node).
|
// its item list - the page itself has no filesystem access
|
||||||
// The folder is re-read every minute, so images added or removed show up
|
// (contextIsolation, no Node). The folder is re-read every minute, so
|
||||||
// without restarting the kiosk.
|
// 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):
|
// Optional slideshow.json in the folder (all keys optional):
|
||||||
// { "interval": 10, seconds per image (default 10, minimum 2)
|
// { "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
|
// "shuffle": false, random order instead of by file name
|
||||||
// "fit": "contain", "contain" (whole image, letterboxed) or
|
// "fit": "contain", images: "contain" (whole image, letterboxed)
|
||||||
// "cover" (fill the screen, cropped)
|
// or "cover" (fill the screen, cropped)
|
||||||
// "transition": 1, crossfade seconds (0 = instant cut)
|
// "transition": 1, crossfade seconds (0 = instant cut)
|
||||||
// "recursive": false } include images in subfolders
|
// "recursive": false } include files in subfolders
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -23,13 +32,16 @@ const { fileURLToPath, pathToFileURL } = require('url');
|
|||||||
|
|
||||||
const SLIDESHOW_PAGE = path.join(__dirname, 'slideshow.html');
|
const SLIDESHOW_PAGE = path.join(__dirname, 'slideshow.html');
|
||||||
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.avif']);
|
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.avif']);
|
||||||
|
const DOC_EXT = new Set(['.docx']);
|
||||||
const REFRESH_MS = 60000;
|
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.
|
// 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 "/"
|
// 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 -
|
// (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) {
|
function slideshowDir(url) {
|
||||||
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
|
||||||
let p;
|
let p;
|
||||||
@@ -57,20 +69,22 @@ function resolveSiteUrl(url) {
|
|||||||
|
|
||||||
function readOptions(dir) {
|
function readOptions(dir) {
|
||||||
const opts = { ...DEFAULTS };
|
const opts = { ...DEFAULTS };
|
||||||
|
let user = {};
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(fs.readFileSync(path.join(dir, 'slideshow.json'), 'utf8'));
|
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;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// No (or unreadable) slideshow.json - defaults.
|
// 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;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listImages(dir, recursive, depth = 0) {
|
function listItems(dir, recursive, depth = 0) {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
@@ -79,12 +93,18 @@ function listImages(dir, recursive, depth = 0) {
|
|||||||
}
|
}
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const e of entries) {
|
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 full = path.join(dir, e.name);
|
||||||
|
const ext = path.extname(e.name).toLowerCase();
|
||||||
if (e.isDirectory()) {
|
if (e.isDirectory()) {
|
||||||
if (recursive && depth < 8) out.push(...listImages(full, recursive, depth + 1));
|
if (recursive && depth < 8) out.push(...listItems(full, recursive, depth + 1));
|
||||||
} else if (IMAGE_EXT.has(path.extname(e.name).toLowerCase())) {
|
} else if (IMAGE_EXT.has(ext)) {
|
||||||
out.push(full);
|
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;
|
return out;
|
||||||
@@ -92,36 +112,59 @@ function listImages(dir, recursive, depth = 0) {
|
|||||||
|
|
||||||
function snapshot(dir) {
|
function snapshot(dir) {
|
||||||
const options = readOptions(dir);
|
const options = readOptions(dir);
|
||||||
const files = listImages(dir, options.recursive)
|
const items = listItems(dir, options.recursive)
|
||||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }))
|
||||||
return {
|
.map((it) => ({ url: pathToFileURL(it.path).href, type: it.type, ...(it.mtime ? { mtime: it.mtime } : {}) }));
|
||||||
dir,
|
return { dir, exists: fs.existsSync(dir), options, items };
|
||||||
exists: fs.existsSync(dir),
|
|
||||||
options,
|
|
||||||
images: files.map((f) => pathToFileURL(f).href),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed a view's slideshow page its image list now and every REFRESH_MS
|
function readDoc(fileUrl) {
|
||||||
// while it stays on a slideshow. Call once per view (main.js does, right
|
try {
|
||||||
// after creating it); safe on views that never show a slideshow.
|
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) {
|
function attach(webContents) {
|
||||||
|
const pagePrefix = pathToFileURL(SLIDESHOW_PAGE).href;
|
||||||
let lastSent = '';
|
let lastSent = '';
|
||||||
|
let current = null; // latest snapshot sent to the page
|
||||||
|
|
||||||
|
const onSlideshow = () => !webContents.isDestroyed() && webContents.getURL().startsWith(pagePrefix);
|
||||||
|
|
||||||
const send = (force) => {
|
const send = (force) => {
|
||||||
if (webContents.isDestroyed()) return;
|
if (!onSlideshow()) return;
|
||||||
const current = webContents.getURL();
|
const dir = new URL(webContents.getURL()).searchParams.get('dir');
|
||||||
if (!current.startsWith(pathToFileURL(SLIDESHOW_PAGE).href)) return;
|
|
||||||
const dir = new URL(current).searchParams.get('dir');
|
|
||||||
if (!dir) return;
|
if (!dir) return;
|
||||||
const payload = JSON.stringify(snapshot(dir));
|
const snap = snapshot(dir);
|
||||||
|
const payload = JSON.stringify(snap);
|
||||||
if (!force && payload === lastSent) return;
|
if (!force && payload === lastSent) return;
|
||||||
lastSent = payload;
|
lastSent = payload;
|
||||||
|
current = snap;
|
||||||
webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`)
|
webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
webContents.on('did-finish-load', () => send(true));
|
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);
|
const timer = setInterval(() => send(false), REFRESH_MS);
|
||||||
webContents.once('destroyed', () => clearInterval(timer));
|
webContents.once('destroyed', () => clearInterval(timer));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot };
|
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot, TITLE_REQUEST };
|
||||||
|
|||||||
Reference in New Issue
Block a user