Initial import

This commit is contained in:
2026-01-25 13:26:45 +01:00
parent a5fe0f73a0
commit f4b7fb62f5
8 changed files with 834 additions and 149 deletions

View File

@@ -7,6 +7,59 @@
<style>
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
/* Slide transitions (applied by JS via classes) */
#stage .slide {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
#stage .slide.enter {
opacity: 0;
transform: translateX(16px);
}
#stage.transition-none .slide.enter {
opacity: 1;
transform: none;
}
#stage.transition-fade .slide {
transition: opacity 420ms ease;
}
#stage.transition-fade .slide.enter {
opacity: 0;
transform: none;
}
#stage.transition-fade .slide.enter.active {
opacity: 1;
}
#stage.transition-fade .slide.exit {
opacity: 1;
transition: opacity 420ms ease;
}
#stage.transition-fade .slide.exit.active {
opacity: 0;
}
#stage.transition-slide .slide {
transition: transform 420ms ease, opacity 420ms ease;
}
#stage.transition-slide .slide.enter {
opacity: 0;
transform: translateX(48px);
}
#stage.transition-slide .slide.enter.active {
opacity: 1;
transform: translateX(0);
}
#stage.transition-slide .slide.exit {
opacity: 1;
transform: translateX(0);
}
#stage.transition-slide .slide.exit.active {
opacity: 0;
transform: translateX(-48px);
}
#notice {
position: fixed;
inset: 0;
@@ -84,6 +137,18 @@
let idx = 0;
let timer = null;
const ANIM_MS = 420;
function getTransitionMode(pl) {
const v = (pl && pl.transition ? String(pl.transition) : 'none').toLowerCase();
return (v === 'fade' || v === 'slide' || v === 'none') ? v : 'none';
}
function applyTransitionClass(mode) {
stage.classList.remove('transition-none', 'transition-fade', 'transition-slide');
stage.classList.add(`transition-${mode}`);
}
async function fetchPlaylist() {
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
@@ -99,6 +164,78 @@
stage.innerHTML = '';
}
function setSlideContent(container, item) {
if (item.type === 'image') {
const el = document.createElement('img');
el.src = item.src;
container.appendChild(el);
} else if (item.type === 'video') {
const el = document.createElement('video');
el.src = item.src;
el.autoplay = true;
el.muted = true;
el.playsInline = true;
el.onended = next;
container.appendChild(el);
} else if (item.type === 'webpage') {
const el = document.createElement('iframe');
el.src = item.url;
container.appendChild(el);
} else if (item.type === 'youtube') {
const el = document.createElement('iframe');
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
// Add common playback params client-side.
const u = item.url || '';
const sep = u.includes('?') ? '&' : '?';
el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`;
container.appendChild(el);
}
}
function showItemWithTransition(item) {
const mode = getTransitionMode(playlist);
applyTransitionClass(mode);
// Create new slide container.
const slide = document.createElement('div');
slide.className = 'slide enter';
setSlideContent(slide, item);
// Determine previous slide (if any).
const prev = stage.querySelector('.slide');
// First render: no animation needed.
if (!prev || mode === 'none') {
stage.innerHTML = '';
slide.classList.remove('enter');
stage.appendChild(slide);
return;
}
// Transition: keep both on stage and animate.
stage.appendChild(slide);
// Trigger transition.
// In some browsers the style changes can get coalesced into a single paint (no animation),
// especially on fast/fullscreen pages. We force a layout read before activating.
requestAnimationFrame(() => {
prev.classList.add('exit');
// Force reflow so the browser commits initial (enter) styles.
// eslint-disable-next-line no-unused-expressions
slide.offsetHeight;
slide.classList.add('active');
prev.classList.add('active');
});
// Cleanup after animation.
window.setTimeout(() => {
try {
if (prev && prev.parentNode === stage) stage.removeChild(prev);
slide.classList.remove('enter');
} catch(e) { /* ignore */ }
}, ANIM_MS + 50);
}
function next() {
if (!playlist || !playlist.items || playlist.items.length === 0) {
setNotice('No playlists assigned.');
@@ -109,36 +246,19 @@
const item = playlist.items[idx % playlist.items.length];
idx = (idx + 1) % playlist.items.length;
clearStage();
// Clear any active timers (but keep DOM for transition).
if (timer) { clearTimeout(timer); timer = null; }
setNotice('');
showItemWithTransition(item);
if (item.type === 'image') {
const el = document.createElement('img');
el.src = item.src;
stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'video') {
const el = document.createElement('video');
el.src = item.src;
el.autoplay = true;
el.muted = true;
el.playsInline = true;
el.onended = next;
stage.appendChild(el);
// next() is called on video end.
} else if (item.type === 'webpage') {
const el = document.createElement('iframe');
el.src = item.url;
stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'youtube') {
const el = document.createElement('iframe');
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
// Add common playback params client-side.
const u = item.url || '';
const sep = u.includes('?') ? '&' : '?';
el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`;
stage.appendChild(el);
// YouTube iframes don't reliably emit an "ended" event without the JS API.
// We keep it simple: play for the configured duration (default 30s).
timer = setTimeout(next, (item.duration || 30) * 1000);
@@ -151,6 +271,7 @@
try {
playlist = await fetchPlaylist();
idx = 0;
applyTransitionClass(getTransitionMode(playlist));
next();
} catch (e) {
clearStage();
@@ -181,6 +302,7 @@
playlist = newPlaylist;
if (oldStr !== newStr) {
idx = 0;
applyTransitionClass(getTransitionMode(playlist));
next();
}