Initial import
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user