SlideCast
Digital signage system for product slideshows
Overview
A two-part digital signage system for displaying product slideshows with animated price overlays. Consists of the SlideCast Display app (runs on the screen device) and SlideCast Admin app (runs on your management computer).
- Auto-discovery of display devices on the local network using Bonjour/mDNS
- Real-time slide updates without restarting the display
- Animated price overlays with multiple visual styles (Glass, Solid, Gradient)
- Ken Burns effect with subtle pan & zoom on slide images
- Fullscreen kiosk mode with auto-start on Windows login
- Multi-monitor support for selecting which screen displays the slideshow
Key Features
Network Service with Auto-Discovery
The Display app runs an embedded Express server on port 3000 and advertises itself via Bonjour/mDNS, allowing the Admin app to automatically discover and connect to display instances on the network.
const express = require('express');
const { Bonjour } = require('bonjour-service');
const bonjour = new Bonjour();
const server = express();
server.use(express.json());
server.get('/api/slides', (req, res) => {
res.json(store.get('slides', []));
});
server.post('/api/slides', (req, res) => {
store.set('slides', req.body);
if (slideshowWindow && !slideshowWindow.isDestroyed()) {
slideshowWindow.webContents.send('slides-updated');
}
res.json({ success: true });
});
server.post('/api/upload', upload.single('image'), (req, res) => {
if (req.file) {
res.json({ filename: req.file.filename });
} else {
res.status(400).json({ error: "No file uploaded" });
}
});
server.listen(3000, '0.0.0.0', () => {
console.log('Server listening on port 3000');
bonjour.publish({ name: 'SlideCast-' + os.hostname(), type: 'slidecast', port: 3000 });
});
Fullscreen Kiosk Mode
The Display app runs in fullscreen without borders or taskbar and registers itself to start automatically on Windows login for unattended operation.
// Auto-start on Windows login
app.setLoginItemSettings({
openAtLogin: true,
path: app.getPath('exe')
});
function launchSlideshow(displayId) {
const displays = screen.getAllDisplays();
let targetDisplay = displays[0];
if (displayId) {
targetDisplay = displays.find(d => d.id === parseInt(displayId)) || displays[0];
}
slideshowWindow = new BrowserWindow({
x: targetDisplay.bounds.x,
y: targetDisplay.bounds.y,
fullscreen: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true
}
});
slideshowWindow.loadFile('slideshow.html');
}
Animated Price Overlay System
Price overlays animate across the screen with randomly selected visual styles. Each overlay displays RRP (strikethrough), sale price, and percentage discount.
function updateOverlay(slide) {
const overlay = document.getElementById('price-overlay');
const animations = ['anim-slideAcross'];
const themes = ['style-glass', 'style-solid', 'style-gradient'];
// Randomly select animation and theme
let randomAnim = animations[Math.floor(Math.random() * animations.length)];
let randomTheme = themes[Math.floor(Math.random() * themes.length)];
// Force restart of CSS animation
overlay.style.animation = 'none';
overlay.className = 'price-overlay';
void overlay.offsetWidth;
overlay.className = `price-overlay ${randomAnim} ${randomTheme}`;
overlay.style.animationDuration = `${SLIDE_DURATION / 1000}s`;
const rrp = parseFloat(slide.rrp) || 0;
const sale = parseFloat(slide.salePrice) || 0;
let pctOff = (rrp > sale && rrp > 0) ? Math.round(((rrp - sale) / rrp) * 100) : 0;
overlay.innerHTML = `
RRP
£${rrp.toFixed(2)}
Now Only
£${sale.toFixed(2)}
${pctOff}% OFF
`;
}
Crossfade Slide Transitions
Slides transition with smooth 1.5-second crossfade animations using CSS transitions and layered div elements.
function showSlide(index) {
const slide = slides[index];
const container = document.getElementById('slideshow-container');
// Create a new layer for smooth crossfade
const layer = document.createElement('div');
layer.className = 'slide-layer';
let imgUrl = '';
if (slide.imagePath) {
if (slide.imagePath.startsWith('http') || slide.imagePath.startsWith('file://')) {
imgUrl = slide.imagePath;
} else {
imgUrl = `${API_BASE}/uploads/${slide.imagePath}`;
}
}
layer.style.backgroundImage = `url("${imgUrl}")`;
container.appendChild(layer);
// Trigger reflow so the active class animates
void layer.offsetWidth;
layer.classList.add('active');
// Update overlay
updateOverlay(slide);
// Remove old layers after fade out
const prevLayers = document.querySelectorAll('.slide-layer:not(:last-child)');
setTimeout(() => {
prevLayers.forEach(l => l.remove());
}, 1500); // match CSS transition duration
}
Multi-Display Support
Queries available displays and allows launching the slideshow on any connected monitor.
server.get('/api/displays', (req, res) => {
if (!app.isReady()) return res.json([]);
res.json(screen.getAllDisplays().map(d => ({
id: d.id,
bounds: d.bounds,
workArea: d.workArea,
scaleFactor: d.scaleFactor,
label: d.label || `Display ${d.id}`
})));
});
server.post('/api/launch', (req, res) => {
launchSlideshow(req.body.displayId);
res.json({ success: true });
});
Challenges & Solutions
- mDNS auto-discovery: Used bonjour-service library to advertise and discover services on the local network without manual IP configuration
- Crossfade transitions: Used CSS transitions with layered div elements to create smooth crossfade effects between slides
- Image serving: Used multer for file uploads and Express static files to serve images to both local Electron and remote Admin app
- Multi-monitor support: Used Electron's screen API to enumerate displays and position the fullscreen window on the selected monitor