foxygit / infodisplay Log in
commits tags

/kiosk/static/display.js · 44.46 KB

raw
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, '&lt;')
    .replace(/>/g, '&gt;');
  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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

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(() => {});
});