Folder image slideshow sites; Electron 42 -> 44

Slideshow: a site whose path is a local folder (/home/kiosk/photos/, or
any existing directory) plays its images full-screen instead of showing
Chromium's directory listing. kiosk-app/slideshow.js maps the folder URL
to slideshow.html (resolveSiteUrl - used where main.js loads a view and
where attachView re-checks the configured URL) and pushes the image list
into the page from the main process (the page has no fs access), re-read
every minute so added/removed images show up without a restart. Natural
file-name order, crossfade, letterboxed by default; optional
slideshow.json in the folder: interval, shuffle, fit, transition,
recursive. provision_install_app already copies kiosk-app/*.js/*.html,
so new installs and Advanced -> Upgrade pick it up.

Electron: ^42.0.0 -> ^44.0.0. Electron supports the latest three majors
(44/43/42), so 42 drops out when 45 ships. Checked the 43/44 breaking
changes against kiosk-app: nothing it uses changed (no clipboard,
safeStorage, net.request, utilityProcess; BrowserView is deprecated but
still present through 46). The offline bundle builder now retries
Electron's install.js download, which crashes outright if a large
download is cut off mid-stream.

Tested with the real main.js on Electron 44.5.1 under Xvfb + Openbox:
slideshow fills the screen, cycles in order (01, 02, 10) skipping a .txt
file, honours slideshow.json, and an image added mid-run joined the
rotation after the refresh. Offline bundle rebuilt with Electron 44.5.1.

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 14:08:50 +00:00
parent 5792e201b6
commit d4b75ca1b9
7 changed files with 266 additions and 6 deletions
+5 -1
View File
@@ -4,6 +4,8 @@ const fs=require('fs');
const path=require('path');
const os=require('os');
const crypto=require('crypto');
// Sites that are a local folder show as an image slideshow (slideshow.js).
const slideshow=require('./slideshow');
// Suppress EPIPE errors (happen when no terminal attached)
process.stdout.on('error',(e)=>{if(e.code!=='EPIPE')throw e;});
@@ -782,7 +784,7 @@ function attachView(i){
const tabIdx=viewIndexToTabIndex(i);
if(tabIdx>=0&&tabs[tabIdx]){
const configuredUrl=tabs[tabIdx].url;
const configuredUrl=slideshow.resolveSiteUrl(tabs[tabIdx].url);
const currentUrl=views[i].webContents.getURL();
if(currentUrl&&!currentUrl.startsWith(configuredUrl)){
@@ -1334,6 +1336,7 @@ async function createWindow(){
url=u.toString();
}catch(e){}
}
url=slideshow.resolveSiteUrl(url);
const initialOrigin=new URL(t.url).origin;
@@ -1367,6 +1370,7 @@ async function createWindow(){
});
view.webContents.setAudioMuted(false);
slideshow.attach(view.webContents);
view.webContents.loadURL(url);
view.webContents.on('did-finish-load',()=>{
+1 -1
View File
@@ -3,6 +3,6 @@
"version": "1.0.0",
"main": "main.js",
"dependencies": {
"electron": "^42.0.0"
"electron": "^44.0.0"
}
}
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<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(). -->
<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;
}
.slide.show { opacity: 1; }
#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>
</head>
<body>
<img class="slide" id="a" alt="">
<img class="slide" id="b" alt="">
<div id="msg">Loading slideshow…</div>
<script>
(() => {
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 state = null;
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 showMessage(text) {
msg.textContent = text;
msg.style.display = 'flex';
layers.forEach((l) => { l.classList.remove('show'); l.removeAttribute('src'); });
}
// 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);
};
back.onerror = () => next(attempts + 1);
back.src = order[pos];
}
window.kioskSlideshow = {
update(s) {
const first = !state;
const sameImages = state && JSON.stringify(state.images) === JSON.stringify(s.images);
const current = order[pos];
state = s;
layers.forEach((l) => {
l.style.objectFit = s.options.fit;
l.style.transitionDuration = s.options.transition + 's';
});
if (!s.images.length) {
order = [];
clearTimeout(timer);
showMessage(s.exists ? `No images 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 (keep >= 0 && !first) {
pos = keep;
clearTimeout(timer);
if (order.length > 1) timer = setTimeout(next, s.options.interval * 1000);
} else {
pos = -1;
next();
}
},
};
})();
</script>
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
// Folder slideshow support for main.js.
//
// 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.
//
// Optional slideshow.json in the folder (all keys optional):
// { "interval": 10, seconds per image (default 10, minimum 2)
// "shuffle": false, random order instead of by file name
// "fit": "contain", "contain" (whole image, letterboxed) or
// "cover" (fill the screen, cropped)
// "transition": 1, crossfade seconds (0 = instant cut)
// "recursive": false } include images in subfolders
const fs = require('fs');
const path = require('path');
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 REFRESH_MS = 60000;
const DEFAULTS = { interval: 10, 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).
function slideshowDir(url) {
if (typeof url !== 'string' || !url.startsWith('file://')) return null;
let p;
try {
p = fileURLToPath(url);
} catch (e) {
return null;
}
if (url.endsWith('/')) return p;
try {
return fs.statSync(p).isDirectory() ? p : null;
} catch (e) {
return null;
}
}
// What a view should actually load for a configured site URL.
function resolveSiteUrl(url) {
const dir = slideshowDir(url);
if (!dir) return url;
const u = pathToFileURL(SLIDESHOW_PAGE);
u.searchParams.set('dir', dir);
return u.href;
}
function readOptions(dir) {
const opts = { ...DEFAULTS };
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;
} catch (e) {
// No (or unreadable) slideshow.json - defaults.
}
return opts;
}
function listImages(dir, recursive, depth = 0) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return [];
}
const out = [];
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const full = path.join(dir, e.name);
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);
}
}
return out;
}
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),
};
}
// 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 attach(webContents) {
let lastSent = '';
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 (!dir) return;
const payload = JSON.stringify(snapshot(dir));
if (!force && payload === lastSent) return;
lastSent = payload;
webContents.executeJavaScript(`window.kioskSlideshow&&window.kioskSlideshow.update(${payload})`)
.catch(() => {});
};
webContents.on('did-finish-load', () => send(true));
const timer = setInterval(() => send(false), REFRESH_MS);
webContents.once('destroyed', () => clearInterval(timer));
}
module.exports = { resolveSiteUrl, slideshowDir, attach, snapshot };