commits
tags
const socket = io({ reconnection: true, reconnectionDelay: 1000 });
// Hide cursor — blank 1px GIF as custom cursor beats cursor:none in some Chromium builds
(function(){
const BLANK = 'url("data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==") 0 0, none';
const s = document.createElement('style');
s.textContent = `*, *::before, *::after { cursor: ${BLANK} !important; }`;
document.head.appendChild(s);
// Belt-and-suspenders: reset on every mousemove in case something overrides it
document.addEventListener('mousemove', () => {
if (document.documentElement.style.cursor !== 'none')
document.documentElement.style.setProperty('cursor', 'none', 'important');
}, { passive: true });
})();
// Theme handling: read 'kiosk_theme' = 'auto'|'dark'|'light' from localStorage
// Exposes window.kioskTheme.set/get for external toggles.
(function(){
const KEY = 'kiosk_theme';
const mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
function apply(pref){
let dark;
if (pref === 'dark') dark = true;
else if (pref === 'light') dark = false;
else dark = !!(mq && mq.matches);
document.documentElement.classList.toggle('dark', dark);
}
const pref = localStorage.getItem(KEY) || 'auto';
apply(pref);
if (mq && mq.addEventListener) {
mq.addEventListener('change', () => {
// only react to system changes when in 'auto' mode
if ((localStorage.getItem(KEY) || 'auto') === 'auto') apply('auto');
});
}
window.kioskTheme = {
set: (p) => { localStorage.setItem(KEY, p); apply(p); window.dispatchEvent(new Event('kiosk-theme-changed')); },
get: () => localStorage.getItem(KEY) || 'auto'
};
// Sync theme when changed in other tabs
window.addEventListener('storage', (e) => {
if (e.key === KEY) apply(e.newValue || 'auto');
});
})();
function parseBBCode(raw) {
// HTML-escape first, then apply BBCode
let s = raw
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
s = s
.replace(/\[b\]([\s\S]*?)\[\/b\]/g, '<strong>$1</strong>')
.replace(/\[i\]([\s\S]*?)\[\/i\]/g, '<em>$1</em>')
.replace(/\[u\]([\s\S]*?)\[\/u\]/g, '<u>$1</u>')
.replace(/\[s\]([\s\S]*?)\[\/s\]/g, '<s>$1</s>')
.replace(/\[color=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\]([\s\S]*?)\[\/color\]/g,
'<span style="color:$1">$2</span>')
.replace(/\[size=([1-9][0-9]{0,2})\]([\s\S]*?)\[\/size\]/g,
'<span style="font-size:$1px">$2</span>')
.replace(/\[font=([a-zA-Z0-9 ,'\-]+)\]([\s\S]*?)\[\/font\]/g,
'<span style="font-family:$1">$2</span>')
.replace(/\[shadow\]([\s\S]*?)\[\/shadow\]/g,
'<span style="text-shadow:2px 2px 6px #000,0 0 12px #000">$1</span>')
.replace(/\[glow=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\]([\s\S]*?)\[\/glow\]/g,
'<span style="text-shadow:0 0 8px $1,0 0 18px $1,0 0 30px $1">$2</span>');
return s;
}
function hexToRgba(hex, opacity) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${opacity / 100})`;
}
let _ticker = { lines: [], idx: 0, data: null, rssTimer: null, rafId: null };
// Duration in seconds for constant pixel-speed
function _calcDur(spanEl, pps) {
return (window.innerWidth + spanEl.offsetWidth) / (pps || 150);
}
// Escape for use as literal separator (not BBCode)
function _escSep(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
async function _loadRssLines() {
try {
const r = await fetch('/api/ticker/rss');
if (!r.ok) return;
const d = await r.json();
if (d.items && d.items.length) {
const changed = JSON.stringify(d.items) !== JSON.stringify(_ticker.lines);
_ticker.lines = d.items;
// Restart continuous ticker when content changes
if (changed && _ticker.data && _ticker.data.mode === 'continuous') {
_ticker.idx = 0;
_tickerShowLine();
}
}
} catch {}
}
async function applyTicker(data) {
const el = document.getElementById('ticker');
const span = document.getElementById('ticker-text');
span.removeEventListener('animationend', _tickerNextLine);
if (_ticker.rssTimer) { clearInterval(_ticker.rssTimer); _ticker.rssTimer = null; }
if (_ticker.rafId) { cancelAnimationFrame(_ticker.rafId); _ticker.rafId = null; }
const hasContent = data.source === 'rss' || (data.text && data.text.trim());
if (!data.enabled || !hasContent) {
el.style.display = 'none';
return;
}
_ticker.idx = 0;
_ticker.data = data;
if (data.source === 'rss') {
_ticker.lines = []; // rensa innan hämtning så gamla rader inte läcker
await _loadRssLines();
if (!_ticker.lines.length) { el.style.display = 'none'; return; }
const ms = (data.rss_interval || 10) * 60 * 1000;
_ticker.rssTimer = setInterval(_loadRssLines, ms);
} else {
_ticker.lines = data.text.split(/\[br\]|\n/).map(l => l.trim()).filter(l => l);
if (!_ticker.lines.length) { el.style.display = 'none'; return; }
}
// Bar style
el.className = data.position === 'top' ? 'top' : 'bottom';
el.style.background = hexToRgba(data.bar_color || '#000000', (data.bar_opacity != null ? data.bar_opacity : 72));
el.style.padding = `${(data.bar_padding != null ? data.bar_padding : 0.45)}rem 0`;
el.style.display = 'block';
// Text style (global defaults, overridden per-span by BBCode)
span.style.color = data.text_color || '#ffffff';
span.style.fontSize = (data.font_size || 2) + 'rem';
span.style.fontFamily = data.font_family || 'sans-serif';
_tickerShowLine();
// Re-position overlays so they don't overlap the ticker bar
requestAnimationFrame(() => {
_updateChatOverlay();
_updateKioskChatOverlay();
const clockEl = document.getElementById('clock-overlay');
if (clockEl && clockEl.style.display !== 'none') _positionClock(clockEl);
});
}
function _tickerShowLine() {
const span = document.getElementById('ticker-text');
const { lines, idx, data } = _ticker;
const pps = data.pps || 150;
const continuous = data.mode === 'continuous';
const sep = `<span style="opacity:.5;padding:0 .5em">${_escSep(data.separator || '◆')}</span>`;
if (continuous) {
// Build one copy of the band, measure it, then duplicate for seamless looping
const band = lines.map(l => parseBBCode(l)).join(sep);
span.style.animation = 'none';
span.style.transform = '';
span.innerHTML = band + sep;
span.offsetHeight; // reflow to measure one copy
const copyW = span.offsetWidth;
// Duplicate so the loop point is seamless
span.innerHTML = band + sep + band + sep;
if (_ticker.rafId) { cancelAnimationFrame(_ticker.rafId); _ticker.rafId = null; }
let x = window.innerWidth; // start off-screen right
let last = null;
function tickContinuous(ts) {
if (!last) last = ts;
x -= pps * (ts - last) / 1000;
// When first copy has fully exited left, reset by one copy width (seamless)
if (x <= -copyW) x += copyW;
span.style.transform = `translateX(${x}px)`;
last = ts;
_ticker.rafId = requestAnimationFrame(tickContinuous);
}
_ticker.rafId = requestAnimationFrame(tickContinuous);
} else {
span.style.animation = 'none';
span.style.transform = '';
span.innerHTML = parseBBCode(lines[idx]);
span.offsetHeight;
if (lines.length > 1) {
const dur = _calcDur(span, pps).toFixed(2);
span.style.animation = `ticker-scroll ${dur}s linear 1`;
span.addEventListener('animationend', _tickerNextLine, { once: true });
} else {
const dur = _calcDur(span, pps).toFixed(2);
span.style.animation = `ticker-scroll ${dur}s linear infinite`;
}
}
}
function _tickerNextLine() {
_ticker.idx = (_ticker.idx + 1) % _ticker.lines.length;
_tickerShowLine();
}
// --- Chat overlays (YouTube Live Chat + Kiosk livechat, separata) ---
let _currentYtId = null;
let _currentIsYtLive = false; // true = bekräftad live-ström via YT.Player
let _currentType = null;
let _ytSinglePlayer = null; // YT.Player för youtube-typ
// YouTube Live Chat state
let _ytChat = { enabled: false, position: 'right', width_pct: 25, height: 100,
bg_color: '#000000', bg_opacity: 70, jobEnabled: true };
// Kiosk livechat overlay state
let _kioskChat = { enabled: false, position: 'right', width_pct: 30, height: 80, jobEnabled: true };
function applyYtChat(data) {
_ytChat.enabled = !!data.enabled;
_ytChat.position = data.position || 'right';
_ytChat.width_pct = data.width_pct != null ? data.width_pct : 25;
_ytChat.height = data.height != null ? data.height : 100;
_ytChat.bg_color = data.bg_color || '#000000';
_ytChat.bg_opacity = data.bg_opacity != null ? data.bg_opacity : 70;
_updateChatOverlay();
}
function applyKioskChat(data) {
_kioskChat.enabled = !!data.kiosk_enabled;
_kioskChat.position = data.kiosk_position || 'right';
_kioskChat.width_pct = data.kiosk_width_pct != null ? data.kiosk_width_pct : 30;
_kioskChat.height = data.kiosk_height != null ? data.kiosk_height : 80;
_updateKioskChatOverlay();
}
// Kept for backward-compat: old chat_settings events from livechat addon
function applyChat(data) {
applyKioskChat(data);
}
// YouTube Live Chat overlay (bara för live-strömmar)
function _updateChatOverlay() {
const overlay = document.getElementById('chat-overlay');
const frame = document.getElementById('chat-frame');
if (!_ytChat.enabled || !_ytChat.jobEnabled || !_currentYtId || !_currentIsYtLive) {
overlay.style.display = 'none';
return;
}
const src = `https://www.youtube.com/live_chat?is_popout=1&v=${_currentYtId}&embed_domain=${window.location.hostname}`;
try {
const CHAT_KEY = 'kiosk_chat_theme';
const chatPref = localStorage.getItem(CHAT_KEY) || 'auto';
let wantDark;
if (chatPref === 'dark') wantDark = true;
else if (chatPref === 'light') wantDark = false;
else {
const mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
wantDark = !!(mq && mq.matches);
}
const finalSrc = src + (wantDark ? '&dark_theme=1&theme=dark' : '');
if (frame.src !== finalSrc) frame.src = finalSrc;
} catch (e) {
if (frame.src !== src) frame.src = src;
}
_positionOverlay(overlay, _ytChat.position, _ytChat.width_pct, _ytChat.height);
overlay.style.background = hexToRgba(_ytChat.bg_color, _ytChat.bg_opacity);
overlay.style.display = 'block';
}
// Kiosk livechat overlay — inbyggd, ansluter direkt till /chat-namespacet
const _kcSocket = io('/chat');
const _kcMsgs = [];
const KC_MAX = 30;
_kcSocket.on('chat_history', (msgs) => {
const container = document.getElementById('kc-msgs');
if (container) container.innerHTML = '';
_kcMsgs.length = 0;
msgs.forEach(m => {
if (m.kind === 'play_suggestion') _kcAddSuggestion(m);
else _kcAddMsg(m.name, m.text, m.color, m.emoji, m.avatar_url);
});
});
_kcSocket.on('chat_message', (d) => {
_kcAddMsg(d.name, d.text, d.color, d.emoji, d.avatar_url);
});
_kcSocket.on('play_suggestion', (d) => {
_kcAddSuggestion(d);
});
_kcSocket.on('system_message', (d) => {
_kcAddSystem(d.text);
});
_kcSocket.on('viewer_count', (d) => {
const el = document.getElementById('kc-count');
if (el) el.textContent = d.count + ' aktiva';
});
const _KC_IMG_RE = /^(?:https?:\/\/|\/)[^\s"<>]+\.(gif|jpe?g|png|webp|avif)(\?[^\s"<>]*)?$/i;
function _kcRenderContent(text) {
const t = text.trim();
if (_KC_IMG_RE.test(t)) {
const safe = t.replace(/"/g, '%22');
return `<img src="${safe}" style="max-width:180px;max-height:140px;border-radius:7px;display:block;margin-top:.2rem" loading="lazy" onerror="this.style.display='none'">`;
}
return _kcEsc(text);
}
function _kcAvatarHtml(emoji, name, avatarUrl) {
if (avatarUrl) return `<img src="${avatarUrl}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
return _kcEsc(emoji || _kcInitial(name));
}
function _kcAddMsg(name, text, color, emoji, avatarUrl) {
const el = document.createElement('div');
el.className = 'kc-msg';
el.innerHTML = `
<div class="kc-avatar" style="background:${color}22;color:${color}">${_kcAvatarHtml(emoji, name, avatarUrl)}</div>
<div class="kc-body">
<div class="kc-name" style="color:${color}">${_kcEsc(name)}</div>
<div class="kc-text">${_kcRenderContent(text)}</div>
</div>`;
_kcAppend(el);
}
function _kcAddSuggestion(d) {
const short = (d.text || d.url || '').length > 50
? (d.text || d.url).slice(0, 47) + '…'
: (d.text || d.url);
const el = document.createElement('div');
el.className = 'kc-msg';
el.innerHTML = `
<div class="kc-avatar" style="background:${d.color}22;color:${d.color}">${_kcAvatarHtml(d.emoji, d.name, d.avatar_url)}</div>
<div class="kc-body">
<div class="kc-name" style="color:${d.color}">${_kcEsc(d.name)}</div>
<div style="border-left:3px solid #818cf8;padding:.3rem .5rem;background:#818cf815;border-radius:0 6px 6px 0;margin-top:.15rem">
<div style="font-size:.68rem;color:#818cf8;margin-bottom:.1rem">🎬 Förslag till displayen</div>
<div style="font-size:.82rem;color:#e8e8e8;word-break:break-all">${_kcEsc(short)}</div>
</div>
</div>`;
_kcAppend(el);
}
function _kcAddSystem(text) {
const el = document.createElement('div');
el.className = 'kc-system';
el.textContent = text;
_kcAppend(el);
}
function _kcAppend(el) {
const container = document.getElementById('kc-msgs');
if (!container) return;
container.appendChild(el);
_kcMsgs.push(el);
if (_kcMsgs.length > KC_MAX) {
const old = _kcMsgs.shift();
old.remove();
}
}
function _kcInitial(name) { return name.charAt(0).toUpperCase(); }
function _kcEsc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
function _updateKioskChatOverlay() {
const overlay = document.getElementById('kiosk-chat-overlay');
// Visa inte om: inaktiverad, job-döljer, är kiosk_chat-vy, eller YT-live-chat tar samma plats
const ytChatActive = _ytChat.enabled && _ytChat.jobEnabled && _currentIsYtLive;
const showAsOverlay = _kioskChat.enabled && _kioskChat.jobEnabled
&& _currentType !== 'kiosk_chat'
&& !ytChatActive;
if (!showAsOverlay) {
overlay.style.display = 'none';
return;
}
_positionOverlay(overlay, _kioskChat.position, _kioskChat.width_pct, _kioskChat.height);
overlay.style.display = 'flex';
}
function _positionOverlay(el, position, widthPct, heightPct) {
const isRight = position !== 'left';
el.style.right = isRight ? '0' : 'auto';
el.style.left = isRight ? 'auto' : '0';
el.style.width = widthPct + '%';
// Offset for ticker bar if it is visible
const ticker = document.getElementById('ticker');
const tickerH = (ticker && ticker.offsetHeight > 0) ? ticker.offsetHeight : 0;
const topOff = (ticker && ticker.classList.contains('top')) ? tickerH : 0;
const bottomOff = (ticker && ticker.classList.contains('bottom')) ? tickerH : 0;
const availH = window.innerHeight - topOff - bottomOff;
const wantH = Math.round(availH * heightPct / 100);
const topPx = Math.round(topOff + (availH - wantH) / 2);
el.style.height = wantH + 'px';
el.style.top = topPx + 'px';
el.style.opacity = '1';
}
socket.on('chat_settings', (data) => {
// Kiosk livechat settings (from livechat addon)
applyKioskChat(data);
});
socket.on('yt_chat_settings', (data) => {
applyYtChat(data);
});
// --- Clock overlay ---
let _clock = { enabled: false, style: 'digital', show_date: false, position: 'top-right',
size: 2, color: '#ffffff', opacity: 90, schedule_mode: 'always',
_timer: null, _raf: null };
let _isScheduled = false;
function applyClockSettings(data) {
_clock.enabled = !!data.enabled;
_clock.style = data.style || 'digital';
_clock.show_date = !!data.show_date;
_clock.position = data.position || 'top-right';
_clock.size = data.size != null ? data.size : 2;
_clock.color = data.color || '#ffffff';
_clock.opacity = data.opacity != null ? data.opacity : 90;
_clock.schedule_mode = data.schedule_mode || 'always';
_refreshClock();
}
function _refreshClock() {
if (_clock._timer) { clearInterval(_clock._timer); _clock._timer = null; }
if (_clock._raf) { cancelAnimationFrame(_clock._raf); _clock._raf = null; }
const el = document.getElementById('clock-overlay');
const digital = document.getElementById('clock-digital');
const canvas = document.getElementById('clock-analog');
const dateEl = document.getElementById('clock-date');
const shouldShow = _clock.enabled &&
(_clock.schedule_mode !== 'scheduled_only' || _isScheduled);
if (!shouldShow) { el.style.display = 'none'; return; }
const sz = _clock.size;
const em = (sz * 2 + 2); // 4rem–12rem
const px = Math.round(sz * 60 + 60); // 120px–360px
digital.style.display = (_clock.style === 'digital') ? 'block' : 'none';
canvas.style.display = (_clock.style === 'analog') ? 'block' : 'none';
digital.style.fontSize = em + 'rem';
digital.style.fontFamily = 'monospace';
digital.style.color = _clock.color;
digital.style.textShadow = '0 2px 12px rgba(0,0,0,0.85)';
digital.style.lineHeight = '1';
canvas.width = px;
canvas.height = px;
const dateSz = Math.max(1.2, em * 0.28);
dateEl.style.display = _clock.show_date ? 'block' : 'none';
dateEl.style.fontSize = dateSz + 'rem';
dateEl.style.color = _clock.color;
dateEl.style.fontFamily = 'monospace';
dateEl.style.textShadow = '0 2px 8px rgba(0,0,0,0.85)';
dateEl.style.marginTop = '0.4rem';
dateEl.style.textAlign = 'center';
el.style.opacity = (_clock.opacity / 100).toString();
el.style.display = 'flex';
_positionClock(el);
_renderClock();
if (_clock.style === 'analog') {
(function loop() { _renderClock(); _clock._raf = requestAnimationFrame(loop); })();
} else {
_clock._timer = setInterval(_renderClock, 1000);
}
}
function _renderClock() {
const now = new Date();
if (_clock.style === 'digital') {
const el = document.getElementById('clock-digital');
if (el) el.textContent =
String(now.getHours()).padStart(2,'0') + ':' +
String(now.getMinutes()).padStart(2,'0') + ':' +
String(now.getSeconds()).padStart(2,'0');
} else {
_drawAnalogClock(now);
}
if (_clock.show_date) {
const days = ['Sön','Mån','Tis','Ons','Tor','Fre','Lör'];
const months = ['jan','feb','mar','apr','maj','jun','jul','aug','sep','okt','nov','dec'];
const el = document.getElementById('clock-date');
if (el) el.textContent = days[now.getDay()] + ' ' + now.getDate() + ' ' + months[now.getMonth()];
}
}
function _drawAnalogClock(now) {
const canvas = document.getElementById('clock-analog');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const cx = W / 2, cy = H / 2;
const R = W / 2 - 2;
const c = _clock.color;
ctx.clearRect(0, 0, W, H);
// Face
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fill();
ctx.strokeStyle = c; ctx.lineWidth = Math.max(2, R * 0.03); ctx.stroke();
// Ticks
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
const outer = R * 0.92;
const inner = (i % 3 === 0) ? R * 0.76 : R * 0.84;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * outer, cy + Math.sin(a) * outer);
ctx.lineTo(cx + Math.cos(a) * inner, cy + Math.sin(a) * inner);
ctx.strokeStyle = c;
ctx.lineWidth = (i % 3 === 0) ? Math.max(2, R * 0.04) : Math.max(1, R * 0.02);
ctx.stroke();
}
const h = now.getHours() % 12;
const m = now.getMinutes();
const s = now.getSeconds();
const ms = now.getMilliseconds();
const hA = ((h + m / 60) / 12) * Math.PI * 2 - Math.PI / 2;
const mA = ((m + s / 60) / 60) * Math.PI * 2 - Math.PI / 2;
const sA = ((s + ms / 1000) / 60) * Math.PI * 2 - Math.PI / 2;
_hand(ctx, cx, cy, hA, R * 0.55, Math.max(3, R * 0.055), c);
_hand(ctx, cx, cy, mA, R * 0.78, Math.max(2, R * 0.035), c);
_hand(ctx, cx, cy, sA, R * 0.88, Math.max(1, R * 0.018), '#ef4444');
_hand(ctx, cx, cy, sA + Math.PI, R * 0.18, Math.max(1, R * 0.018), '#ef4444');
ctx.beginPath(); ctx.arc(cx, cy, Math.max(3, R * 0.045), 0, Math.PI * 2);
ctx.fillStyle = c; ctx.fill();
ctx.beginPath(); ctx.arc(cx, cy, Math.max(2, R * 0.025), 0, Math.PI * 2);
ctx.fillStyle = '#ef4444'; ctx.fill();
}
function _hand(ctx, cx, cy, angle, len, width, color) {
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(angle) * len, cy + Math.sin(angle) * len);
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.stroke();
}
function _positionClock(el) {
const pos = _clock.position;
const pad = 18;
const ticker = document.getElementById('ticker');
const tickerH = (ticker && ticker.offsetHeight > 0) ? ticker.offsetHeight : 0;
const topOff = (ticker && ticker.classList.contains('top')) ? tickerH : 0;
const botOff = (ticker && ticker.classList.contains('bottom')) ? tickerH : 0;
el.style.top = el.style.bottom = el.style.left = el.style.right = el.style.transform = '';
if (pos === 'top-left') { el.style.top = (pad + topOff) + 'px'; el.style.left = pad + 'px'; }
else if (pos === 'top-right') { el.style.top = (pad + topOff) + 'px'; el.style.right = pad + 'px'; }
else if (pos === 'bottom-left') { el.style.bottom = (pad + botOff) + 'px'; el.style.left = pad + 'px'; }
else if (pos === 'bottom-right') { el.style.bottom = (pad + botOff) + 'px'; el.style.right = pad + 'px'; }
else { el.style.top = '50%'; el.style.left = '50%'; el.style.transform = 'translate(-50%,-50%)'; }
}
socket.on('clock_settings', applyClockSettings);
socket.on('connect', () => {
// Server pushar nu all state via socket-events (ticker, chat_settings, etc.)
// Fetch-anrop som fallback om socket-events skulle missa något.
fetch('/api/ticker').then(r => r.json()).then(applyTicker).catch(() => {});
fetch('/api/chat').then(r => r.json()).then(applyKioskChat).catch(() => {});
fetch('/api/yt_chat').then(r => r.json()).then(applyYtChat).catch(() => {});
fetch('/api/clock').then(r => r.json()).then(applyClockSettings).catch(() => {});
});
socket.on('disconnect', () => {
// Nollställ live-state så att nästa connect börjar rent
_currentIsYtLive = false;
if (_ytSinglePlayer) { try { _ytSinglePlayer.destroy(); } catch {} _ytSinglePlayer = null; }
});
// === Layer transition system ===
const _TX_LAYERS = ['iframe', 'video', 'local', 'image', 'idle'];
let _tx = { type: 'fade', duration: 600 };
let _txRunning = false;
socket.on('transition_config', d => {
_tx.type = d.type || 'fade';
_tx.duration = d.duration || 600;
});
function _txAnimate(el, keyframe, duration) {
return new Promise(resolve => {
el.style.animation = `${keyframe} ${duration}ms ease both`;
const done = () => { el.style.animation = ''; resolve(); };
el.addEventListener('animationend', done, { once: true });
// Säkerhetskopia ifall animationend inte triggar
setTimeout(done, duration + 100);
});
}
function _txPixelDissolve(fromEl, toEl, duration) {
return new Promise(resolve => {
const canvas = document.getElementById('tx-pixel-canvas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.display = 'block';
const BLOCK = 18;
const cols = Math.ceil(canvas.width / BLOCK);
const rows = Math.ceil(canvas.height / BLOCK);
const total = cols * rows;
// Slumpmässig ordning på block
const order = Array.from({ length: total }, (_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
// Starta med att visa toEl under canvasen
toEl.style.display = 'block';
toEl.style.zIndex = '0';
if (fromEl) fromEl.style.zIndex = '1';
canvas.style.zIndex = '99998';
const start = performance.now();
function frame(now) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const filled = Math.floor(progress * total);
// Rita reveal-block (svarta lådor som försvinner = avslöjar ny layer)
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = filled; i < total; i++) {
const idx = order[i];
const cx = (idx % cols) * BLOCK;
const cy = Math.floor(idx / cols) * BLOCK;
// Slumpa färg från svart/mörkgrå för glitch-känsla
const grey = Math.floor(Math.random() * 30);
ctx.fillStyle = `rgb(${grey},${grey},${grey})`;
ctx.fillRect(cx, cy, BLOCK, BLOCK);
}
if (progress < 1) {
requestAnimationFrame(frame);
} else {
canvas.style.display = 'none';
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (fromEl) { fromEl.style.display = 'none'; fromEl.style.zIndex = ''; }
toEl.style.zIndex = '';
resolve();
}
}
requestAnimationFrame(frame);
});
}
/** Säkerställer att #frame finns som en ren iframe i #layer-iframe.
* Anropas alltid innan frame.src sätts, eftersom YT.Player.destroy()
* tar bort elementet från DOM. */
function _ensureFrame() {
const layer = document.getElementById('layer-iframe');
let el = document.getElementById('frame');
if (!el || el.tagName.toLowerCase() !== 'iframe') {
if (el) el.remove();
el = document.createElement('iframe');
el.id = 'frame';
el.style.cssText = 'width:100%;height:100%;border:0;display:block';
layer.appendChild(el);
}
return el;
}
// txOverride: { type, duration } — används för per-jobb-övergångar
function showLayer(name, txOverride) {
const type = (txOverride && txOverride.type) || _tx.type;
const duration = (txOverride && txOverride.duration) || _tx.duration;
const toEl = document.getElementById('layer-' + name);
const fromEl = _TX_LAYERS
.filter(l => l !== name)
.map(l => document.getElementById('layer-' + l))
.find(el => el.style.display !== 'none') || null;
// Direkt-byte: ingen nuvarande synlig layer, ingen övergång vald, eller övergång pågår
if (!fromEl || type === 'none' || _txRunning) {
_TX_LAYERS.forEach(l => { document.getElementById('layer-' + l).style.display = 'none'; });
toEl.style.display = 'block';
return;
}
_txRunning = true;
const IN_ANIMS = {
'fade': 'tx-fade-in',
'slide-left': 'tx-slide-left-in',
'slide-right': 'tx-slide-right-in',
'slide-up': 'tx-slide-up-in',
'slide-down': 'tx-slide-down-in',
'zoom': 'tx-zoom-in',
'wipe': 'tx-wipe-in',
'blur': 'tx-blur-in',
};
const OUT_ANIMS = {
'fade': 'tx-fade-out',
'slide-left': 'tx-slide-left-out',
'slide-right': 'tx-slide-right-out',
'slide-up': 'tx-slide-up-out',
'slide-down': 'tx-slide-down-out',
'zoom': 'tx-zoom-out',
'wipe': 'tx-wipe-out',
'blur': 'tx-blur-out',
};
if (type === 'pixel') {
_txPixelDissolve(fromEl, toEl, duration).then(() => { _txRunning = false; });
return;
}
const inAnim = IN_ANIMS[type] || 'tx-fade-in';
const outAnim = OUT_ANIMS[type] || 'tx-fade-out';
toEl.style.display = 'block';
toEl.style.zIndex = '2';
fromEl.style.zIndex = '1';
Promise.all([
_txAnimate(toEl, inAnim, duration),
_txAnimate(fromEl, outAnim, duration),
]).then(() => {
_TX_LAYERS.forEach(l => {
const el = document.getElementById('layer-' + l);
el.style.zIndex = '';
if (l !== name) el.style.display = 'none';
});
toEl.style.display = 'block';
_txRunning = false;
});
}
socket.on('switch', (data) => {
// Per-job chat override: show_chat=false döljer YT-livechat för jobbet
_ytChat.jobEnabled = (data.show_chat !== false);
_kioskChat.jobEnabled = true; // kiosk-chat styrs bara av sin globala toggle
// Per-jobb övergång: om satt används den för just detta switch, annars global
const jobTx = (data.transition_type)
? { type: data.transition_type, duration: data.transition_duration || _tx.duration }
: null;
// Track scheduled flag for clock schedule_mode
const prevScheduled = _isScheduled;
_isScheduled = (data.scheduled === true);
if (_isScheduled !== prevScheduled && _clock.schedule_mode === 'scheduled_only') {
_refreshClock();
}
// Stop video playlist player when switching away from youtube_playlist
if (_currentType === 'youtube_playlist' && data.type !== 'youtube_playlist') {
if (_vpl.player) { try { _vpl.player.stopVideo(); } catch {} }
}
// Stäng av YT.Player för enskild video om vi byter till annan typ
if (_ytSinglePlayer && data.type !== 'youtube') {
try { _ytSinglePlayer.destroy(); } catch {}
_ytSinglePlayer = null;
_ensureFrame();
}
// Track YouTube video ID and current type for overlays
const ytMatch = (data.source || '').match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/);
_currentYtId = ytMatch ? ytMatch[1] : null;
_currentIsYtLive = false;
_currentType = data.type;
if (data.type === 'kiosk_chat') {
_ensureFrame().src = window.location.origin + '/chat-display';
showLayer('iframe', jobTx);
} else if (data.type === 'url') {
_ensureFrame().src = data.source;
showLayer('iframe', jobTx);
} else if (data.type === 'youtube') {
if (_currentYtId) {
showLayer('iframe', jobTx);
_playYoutubeVideo(_currentYtId);
} else {
_ensureFrame().src = data.source;
showLayer('iframe', jobTx);
}
} else if (data.type === 'video') {
const v = document.getElementById('video');
v.src = data.source;
showLayer('video', jobTx);
v.load();
v.play().catch(() => { v.muted = true; v.play(); });
} else if (data.type === 'image') {
document.getElementById('image').src = data.source;
showLayer('image', jobTx);
} else if (data.type === 'youtube_playlist') {
_vpl.shuffle = !!data.shuffle;
_startVideoPlaylist(data.source, jobTx);
} else if (data.type === 'emulator') {
_ensureFrame().src = '/emulator?rom=' + encodeURIComponent(data.source);
showLayer('iframe', jobTx);
} else if (data.type === 'pdf' || data.type === 'game') {
_ensureFrame().src = data.source;
showLayer('iframe', jobTx);
} else if (data.type === 'local') {
const html = data.html || '';
if (!html && !data.source) {
showLayer('idle', jobTx);
} else {
document.getElementById('layer-local').innerHTML = html;
showLayer('local', jobTx);
}
}
_updateChatOverlay();
_updateKioskChatOverlay();
});
socket.on('ticker', applyTicker);
// --- Video-spellista (youtube_playlist) ---
let _vpl = {
ids: [],
index: 0,
shuffle: false,
shuffleOrder: [],
shufflePos: 0,
player: null,
url: '', // senaste playlist-URL (för cache-nyckel)
};
function _vplNext() {
if (!_vpl.ids.length) return;
if (_vpl.shuffle) {
_vpl.shufflePos++;
if (_vpl.shufflePos >= _vpl.shuffleOrder.length) {
_vpl.shuffleOrder = _musicShuffle(_vpl.ids.map((_, i) => i));
_vpl.shufflePos = 0;
}
_vpl.index = _vpl.shuffleOrder[_vpl.shufflePos];
} else {
_vpl.index = (_vpl.index + 1) % _vpl.ids.length;
}
if (_vpl.player) _vpl.player.loadVideoById(_vpl.ids[_vpl.index]);
}
function _vplStart(ids) {
if (!window.YT || !window.YT.Player) {
// API inte laddat än — försök igen om 500ms
setTimeout(() => _vplStart(ids), 500);
return;
}
_vpl.ids = ids;
_vpl.index = 0;
if (_vpl.shuffle) {
_vpl.shuffleOrder = _musicShuffle(ids.map((_, i) => i));
_vpl.shufflePos = 0;
_vpl.index = _vpl.shuffleOrder[0];
}
const videoId = ids[_vpl.index];
// Rensa gamla spelaren
if (_vpl.player) {
try { _vpl.player.destroy(); } catch {}
_vpl.player = null;
}
// Återskapa <iframe id="frame"> (YT.Player ersätter den)
const layerFrame = document.getElementById('layer-iframe');
const oldFrame = document.getElementById('frame');
if (oldFrame) oldFrame.remove();
const div = document.createElement('div');
div.id = 'frame';
layerFrame.appendChild(div);
_vpl.player = new YT.Player('frame', {
videoId,
playerVars: {
autoplay: 1,
controls: 1,
rel: 0,
modestbranding: 1,
origin: window.location.origin,
},
events: {
onReady(e) { e.target.playVideo(); },
onStateChange(e) {
if (e.data === YT.PlayerState.ENDED) _vplNext();
},
onError(_e) { _vplNext(); },
},
});
}
// --- Enskild YouTube-video via YT.Player (för live-detektering) ---
function _playYoutubeVideo(videoId) {
if (!window.YT || !window.YT.Player) {
setTimeout(() => _playYoutubeVideo(videoId), 500);
return;
}
if (_ytSinglePlayer) {
try { _ytSinglePlayer.destroy(); } catch {}
_ytSinglePlayer = null;
}
const layerFrame = document.getElementById('layer-iframe');
const oldFrame = document.getElementById('frame');
if (oldFrame) oldFrame.remove();
const div = document.createElement('div');
div.id = 'frame';
layerFrame.appendChild(div);
_ytSinglePlayer = new YT.Player('frame', {
videoId,
playerVars: { autoplay: 1, controls: 1, rel: 0, origin: window.location.origin },
events: {
onReady(e) {
e.target.playVideo();
// getDuration() === 0 innebär att videon är en live-ström
_currentIsYtLive = (e.target.getDuration() === 0);
_updateChatOverlay();
},
},
});
}
async function _startVideoPlaylist(url, txOverride) {
_vpl.url = url;
showLayer('iframe', txOverride);
// Försök hämta cachade IDs
let ids = [];
try {
const r = await fetch('/api/playlist/ids?url=' + encodeURIComponent(url));
if (r.ok) {
const d = await r.json();
ids = d.ids || [];
}
} catch {}
if (!ids.length) {
// Inte extraherat än — extrahera nu (kan ta ~10s)
_ensureFrame().src = '';
try {
const r = await fetch('/api/playlist/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (r.ok) {
const d = await r.json();
ids = d.ids || [];
}
} catch {}
}
if (!ids.length) {
console.warn('[VideoPlaylist] Inga videos hittades för', url);
return;
}
if (_music.ytApiReady) {
_vplStart(ids);
} else {
// Vänta på YT API
const orig = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = function() {
if (orig) orig();
_vplStart(ids);
};
}
}
socket.on('sleep_toggle', (data) => {
document.getElementById('layer-sleep').style.display = data.active ? 'block' : 'none';
});
// --- Musikspelaren ---
let _music = {
enabled: false,
source_type: 'local', // 'local' | 'youtube'
source: '',
volume: 50,
shuffle: false,
show_overlay: true,
overlay_position: 'bottom-left',
tracks: [], // [{name, path}] för lokal
trackIndex: 0,
shuffleOrder: [],
shufflePos: 0,
// YouTube IFrame Player
ytPlayer: null,
ytIds: [], // extraherade video-ID:n
ytIndex: 0,
ytShuffleOrder: [],
ytShufflePos: 0,
ytApiReady: false,
};
// Ladda YouTube IFrame Player API en gång
(function() {
if (window._ytApiLoading) return;
window._ytApiLoading = true;
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
})();
window.onYouTubeIframeAPIReady = function() {
_music.ytApiReady = true;
// Om musik redan är aktiverat och väntar på API
if (_music.enabled && _music.source_type === 'youtube' && _music.ytIds.length) {
_musicYtStartPlayer();
}
};
function _musicYtStartPlayer() {
if (!window.YT || !window.YT.Player) return;
const ids = _music.ytIds;
if (!ids.length) return;
// Bestäm vilket index som ska spelas
let startIdx = _music.ytIndex;
if (_music.shuffle) {
if (!_music.ytShuffleOrder.length) {
_music.ytShuffleOrder = _musicShuffle(ids.map((_, i) => i));
_music.ytShufflePos = 0;
}
startIdx = _music.ytShuffleOrder[_music.ytShufflePos % _music.ytShuffleOrder.length];
}
const videoId = ids[startIdx];
if (_music.ytPlayer) {
// Byt video utan att skapa ny player
_music.ytPlayer.loadVideoById(videoId);
_music.ytPlayer.setVolume(_music.volume);
_musicUpdateNowPlaying('Laddar...', '');
return;
}
// Ta bort gammal iframe och skapa ny div som YT.Player tar över
const oldFrame = document.getElementById('music-yt-frame');
if (oldFrame) oldFrame.remove();
const div = document.createElement('div');
div.id = 'music-yt-frame';
document.body.appendChild(div);
_music.ytPlayer = new YT.Player('music-yt-frame', {
width: '320',
height: '180',
videoId: videoId,
playerVars: {
autoplay: 1,
controls: 0,
disablekb: 1,
fs: 0,
rel: 0,
modestbranding: 1,
origin: window.location.origin,
},
events: {
onReady(e) {
e.target.setVolume(_music.volume);
e.target.playVideo();
_musicUpdateNowPlaying('YouTube-spellista', '');
// Flytta iframen off-screen
const iframe = document.getElementById('music-yt-frame');
if (iframe) {
iframe.style.cssText = 'position:fixed;width:320px;height:180px;left:-400px;top:0;pointer-events:none;border:none;';
}
},
onStateChange(e) {
if (e.data === YT.PlayerState.ENDED) {
_musicYtNext();
}
},
onError(_e) {
// Hoppa till nästa vid fel (blockerad video etc.)
_musicYtNext();
},
},
});
}
function _musicYtNext() {
if (!_music.ytIds.length) return;
if (_music.shuffle) {
_music.ytShufflePos++;
if (_music.ytShufflePos >= _music.ytShuffleOrder.length) {
_music.ytShuffleOrder = _musicShuffle(_music.ytIds.map((_, i) => i));
_music.ytShufflePos = 0;
}
_music.ytIndex = _music.ytShuffleOrder[_music.ytShufflePos];
} else {
_music.ytIndex = (_music.ytIndex + 1) % _music.ytIds.length;
}
if (_music.ytPlayer) {
_music.ytPlayer.loadVideoById(_music.ytIds[_music.ytIndex]);
}
}
function _musicParseFilename(filename) {
// Ta bort filändelse
let name = filename.replace(/\.[^.]+$/, '');
// Försök tolka "Artist - Titel" eller "Nr. Artist - Titel"
name = name.replace(/^\d+[\.\-\s]+/, ''); // ta bort spårnummer
const dashIdx = name.indexOf(' - ');
if (dashIdx > 0) {
return { artist: name.slice(0, dashIdx).trim(), title: name.slice(dashIdx + 3).trim() };
}
return { artist: '', title: name.trim() };
}
function _musicShuffle(arr) {
const a = arr.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 _musicSetVolume(vol) {
const audio = document.getElementById('music-player');
if (audio) audio.volume = vol / 100;
}
function _musicUpdateNowPlaying(title, artist) {
const overlay = document.getElementById('now-playing-overlay');
if (!overlay) return;
document.getElementById('now-playing-title').textContent = title;
document.getElementById('now-playing-artist').textContent = artist;
if (_music.enabled && _music.show_overlay && (title || artist)) {
overlay.style.display = 'block';
_musicPositionOverlay(overlay);
// Restart animation
const inner = document.getElementById('now-playing-inner');
inner.style.animation = 'none';
inner.offsetHeight;
inner.style.animation = 'np-fadein .4s ease';
} else {
overlay.style.display = 'none';
}
// Rapportera till server (admin-visning)
fetch('/api/music/nowplaying', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, artist }),
}).catch(() => {});
}
function _musicPositionOverlay(overlay) {
const pos = _music.overlay_position || 'bottom-left';
const pad = 16;
const ticker = document.getElementById('ticker');
const tickerH = (ticker && ticker.offsetHeight > 0) ? ticker.offsetHeight : 0;
const topOff = (ticker && ticker.classList.contains('top')) ? tickerH : 0;
const botOff = (ticker && ticker.classList.contains('bottom')) ? tickerH : 0;
overlay.style.top = overlay.style.bottom = overlay.style.left = overlay.style.right = '';
if (pos === 'top-left') { overlay.style.top = (pad + topOff) + 'px'; overlay.style.left = pad + 'px'; }
if (pos === 'top-right') { overlay.style.top = (pad + topOff) + 'px'; overlay.style.right = pad + 'px'; }
if (pos === 'bottom-left') { overlay.style.bottom = (pad + botOff) + 'px'; overlay.style.left = pad + 'px'; }
if (pos === 'bottom-right') { overlay.style.bottom = (pad + botOff) + 'px'; overlay.style.right = pad + 'px'; }
}
async function _musicLoadTracks(folder) {
try {
const r = await fetch('/api/music/tracks?path=' + encodeURIComponent(folder));
if (!r.ok) return [];
return await r.json();
} catch { return []; }
}
function _musicPlayTrack(idx) {
const audio = document.getElementById('music-player');
if (!audio || !_music.tracks.length) return;
const realIdx = _music.shuffle
? _music.shuffleOrder[_music.shufflePos % _music.shuffleOrder.length]
: idx % _music.tracks.length;
const track = _music.tracks[realIdx];
if (!track) return;
audio.src = '/files?path=' + encodeURIComponent(track.path);
audio.volume = _music.volume / 100;
audio.play().catch(() => {});
const { title, artist } = _musicParseFilename(track.name);
_musicUpdateNowPlaying(title, artist);
}
function _musicNext() {
if (!_music.tracks.length) return;
if (_music.shuffle) {
_music.shufflePos++;
if (_music.shufflePos >= _music.shuffleOrder.length) {
_music.shuffleOrder = _musicShuffle(_music.tracks.map((_, i) => i));
_music.shufflePos = 0;
}
} else {
_music.trackIndex = (_music.trackIndex + 1) % _music.tracks.length;
}
_musicPlayTrack(_music.trackIndex);
}
async function applyMusicSettings(data) {
_music.enabled = !!data.enabled;
_music.source_type = data.source_type || 'local';
_music.source = data.source || '';
_music.volume = data.volume != null ? data.volume : 50;
_music.shuffle = !!data.shuffle;
_music.show_overlay = data.show_overlay !== false;
_music.overlay_position = data.overlay_position || 'bottom-left';
const audio = document.getElementById('music-player');
const ytFrame = document.getElementById('music-yt-frame');
if (!_music.enabled) {
if (audio) { audio.pause(); audio.src = ''; }
if (ytFrame) { ytFrame.src = ''; }
if (_music.ytPlayer) { try { _music.ytPlayer.stopVideo(); } catch {} }
_musicUpdateNowPlaying('', '');
return;
}
_musicSetVolume(_music.volume);
if (_music.source_type === 'youtube') {
// YouTube-spellista via IFrame Player API (extraherade video-ID:n)
if (audio) { audio.pause(); audio.src = ''; }
if (ytFrame) { ytFrame.src = ''; } // stäng eventuell gammal embed-iframe
// Hämta extraherade ID:n från servern
let ids = [];
try {
const r = await fetch('/api/music/playlist');
if (r.ok) {
const d = await r.json();
ids = d.ids || [];
}
} catch { /* ignore */ }
if (!ids.length) {
_musicUpdateNowPlaying('Ingen spellista', 'Extrahera först i admin');
return;
}
_music.ytIds = ids;
_music.ytIndex = 0;
_music.ytShuffleOrder = [];
_music.ytShufflePos = 0;
if (_music.ytApiReady) {
_musicYtStartPlayer();
}
// annars startas spelaren i onYouTubeIframeAPIReady när API:t laddat klart
} else {
// Lokal MP3-spellista
if (ytFrame) { ytFrame.src = ''; }
const tracks = await _musicLoadTracks(_music.source);
_music.tracks = tracks;
_music.trackIndex = 0;
if (!tracks.length) {
_musicUpdateNowPlaying('', '');
return;
}
if (_music.shuffle) {
_music.shuffleOrder = _musicShuffle(tracks.map((_, i) => i));
_music.shufflePos = 0;
}
// Börja bara spela om inget spelas redan (undvik avbrott vid settings-uppdatering)
if (audio && (audio.paused || audio.ended || !audio.src)) {
_musicPlayTrack(0);
} else {
_musicSetVolume(_music.volume);
}
// Sätt upp händelselyssnare för nästa spår
if (audio) {
audio.onended = _musicNext;
}
}
}
socket.on('music_settings', applyMusicSettings);
socket.on('now_playing', () => {
// Ignoreras på display-sidan (vi sänder, vi tar inte emot)
});
// Hämta musikinställningar vid anslutning
socket.on('connect', () => {
fetch('/api/music').then(r => r.json()).then(applyMusicSettings).catch(() => {});
});