diff --git a/wwwroot/js/display.js b/wwwroot/js/display.js
new file mode 100644
index 0000000..f1ae937
--- /dev/null
+++ b/wwwroot/js/display.js
@@ -0,0 +1,104 @@
+// === Sunbeam Display Engine ===
+(function () {
+ 'use strict';
+ var slug = document.body.dataset.slug;
+ var transition = document.body.dataset.transition || 'fade';
+ var slides = window.playlistData || [];
+ var currentIndex = -1, slideTimer = null, layerA = document.getElementById('slide-a'), layerB = document.getElementById('slide-b'), activeLayer = 'a', pollInterval = 30000, icsCache = {};
+
+ function updateClock() {
+ var now = new Date();
+ var timeEl = document.getElementById('clock-time'), dateEl = document.getElementById('clock-date');
+ if (timeEl) timeEl.textContent = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ if (dateEl) dateEl.textContent = now.toLocaleDateString([], { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
+ }
+ updateClock(); setInterval(updateClock, 1000);
+
+ if (!slides || slides.length === 0) {
+ layerA.innerHTML = '
\u{1F431}
No meows in this PURR.
Configure slides in the Scratching Post admin.
';
+ layerA.classList.add('active'); return;
+ }
+
+ function renderSlide(slide, layer) {
+ var bg = slide.backgroundColor || '#0a0a14';
+ var bgImg = slide.backgroundImage ? 'url(' + slide.backgroundImage + ')' : 'none';
+ var css = slide.customCss || '';
+ if (slide.type === 'content') {
+ layer.innerHTML = '' + (slide.content || '') + '
';
+ } else if (slide.type === 'embed') {
+ layer.innerHTML = '';
+ } else if (slide.type === 'icscalendar') {
+ layer.innerHTML = '';
+ loadIcsEvents(slide.icsSource, layer);
+ } else {
+ layer.innerHTML = '' + (slide.content || '') + '
';
+ }
+ }
+
+ function loadIcsEvents(source, layer) {
+ if (!source) { var g = layer.querySelector('.events-grid'); if (g) g.innerHTML = 'No calendar source.
'; return; }
+ var cached = icsCache[source];
+ if (cached && (Date.now() - cached.time) < 300000) { renderIcsEvents(cached.events, layer); return; }
+ fetch('/api/parseics?url=' + encodeURIComponent(source)).then(function (r) { return r.json(); }).then(function (data) {
+ var events = data.events || []; icsCache[source] = { events: events, time: Date.now() }; renderIcsEvents(events, layer);
+ }).catch(function () { var g = layer.querySelector('.events-grid'); if (g) g.innerHTML = 'Could not load calendar.
'; });
+ }
+
+ function renderIcsEvents(events, layer) {
+ var grid = layer.querySelector('.events-grid'); if (!grid) return;
+ if (events.length === 0) { grid.innerHTML = 'No upcoming events.
'; return; }
+ var html = '';
+ events.forEach(function (e) {
+ html += '' + escapeHtml(e.summary || 'Event') + '
';
+ if (e.start) { var d = new Date(e.start); var timeStr = e.allDay ? 'All Day' : formatEventTime(e.start, e.end); html += '
' + d.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }) + ' \u00B7 ' + timeStr + '
'; }
+ if (e.location) html += '
\u{1F4CD} ' + escapeHtml(e.location) + '
';
+ html += '
';
+ });
+ grid.innerHTML = html;
+ }
+
+ function formatEventTime(start, end) {
+ var s = new Date(start), result = s.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ if (end) { var e = new Date(end); result += ' \u2013 ' + e.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }
+ return result;
+ }
+
+ function showNextSlide() {
+ currentIndex = (currentIndex + 1) % slides.length;
+ var slide = slides[currentIndex], incoming, outgoing;
+ if (activeLayer === 'a') { incoming = layerB; outgoing = layerA; activeLayer = 'b'; } else { incoming = layerA; outgoing = layerB; activeLayer = 'a'; }
+ renderSlide(slide, incoming);
+ if (transition === 'slide') outgoing.classList.add('slide-out');
+ incoming.classList.add('active'); outgoing.classList.remove('active');
+ setTimeout(function () { outgoing.classList.remove('slide-out'); outgoing.innerHTML = ''; }, 1200);
+ startProgress(slide.duration);
+ clearTimeout(slideTimer); slideTimer = setTimeout(showNextSlide, slide.duration * 1000);
+ }
+
+ function startProgress(duration) {
+ var fill = document.getElementById('progress-fill'); if (!fill) return;
+ fill.style.transition = 'none'; fill.style.width = '0%'; fill.offsetHeight;
+ fill.style.transition = 'width ' + duration + 's linear'; fill.style.width = '100%';
+ }
+
+ function pollForUpdates() {
+ fetch('/api/playlist/' + slug).then(function (r) { if (!r.ok) throw new Error('Not found'); return r.json(); }).then(function (data) {
+ document.getElementById('status-dot').classList.remove('error');
+ var newSlides = data.slides, changed = newSlides.length !== slides.length;
+ if (!changed) { for (var i = 0; i < slides.length; i++) { if (slides[i].id !== newSlides[i].id || slides[i].duration !== newSlides[i].duration || slides[i].updatedAt !== newSlides[i].updatedAt) { changed = true; break; } } }
+ if (changed) { console.log('[Sunbeam] Playlist updated'); slides = newSlides; icsCache = {}; if (slides.length === 0) { clearTimeout(slideTimer); layerA.innerHTML = '\u{1F431}
No meows in this PURR.
'; layerA.classList.add('active'); layerB.classList.remove('active'); return; } if (currentIndex >= slides.length) currentIndex = -1; }
+ }).catch(function (err) { document.getElementById('status-dot').classList.add('error'); console.warn('[Sunbeam] Poll failed:', err); });
+ }
+
+ function escapeHtml(str) { var div = document.createElement('div'); div.textContent = str; return div.innerHTML; }
+
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'ArrowRight' || e.key === ' ') { clearTimeout(slideTimer); showNextSlide(); }
+ if (e.key === 'ArrowLeft') { currentIndex = Math.max(-1, currentIndex - 2); clearTimeout(slideTimer); showNextSlide(); }
+ if (e.key === 'f' || e.key === 'F11') { e.preventDefault(); if (!document.fullscreenElement) document.documentElement.requestFullscreen(); else document.exitFullscreen(); }
+ if (e.key === 'c') { document.body.style.cursor = document.body.style.cursor === 'default' ? 'none' : 'default'; }
+ });
+
+ console.log('[Sunbeam] Display engine starting for "' + slug + '" with ' + slides.length + ' slide(s)');
+ showNextSlide(); setInterval(pollForUpdates, pollInterval);
+})();