foxygit / infodisplay Log in
commit 15efae07371acb1548d041b1412f63bf9bbd6e6c
Author:     Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Mon Apr 6 13:46:20 2026 +0200
Commit:     Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Mon Apr 6 13:46:20 2026 +0200

    woop woop. förbättringar och buggfixar.
---
 kiosk/app.py                 |  31 +++++++
 kiosk/db.py                  |  38 +++++---
 kiosk/scheduler.py           |   9 +-
 kiosk/static/admin.js        |  59 ++++++++++--
 kiosk/static/display.js      | 216 +++++++++++++++++++++++++++++++++++++------
 kiosk/templates/admin.html   |  60 ++++++++++++
 kiosk/templates/display.html |  45 +++++++++
 7 files changed, 405 insertions(+), 53 deletions(-)

diff --git a/kiosk/app.py b/kiosk/app.py
index 75ffc82..ddf05fa 100644
--- a/kiosk/app.py
+++ b/kiosk/app.py
@@ -168,6 +168,8 @@ def add_job():
         data['label'], float(data['duration']), data['type'], data['source'],
         shuffle=bool(data.get('shuffle', False)),
         show_chat=bool(data.get('show_chat', False)),
+        transition_type=str(data.get('transition_type', '')),
+        transition_duration=int(data.get('transition_duration', 0)),
     )
     sched.reload_jobs()
     return jsonify({'id': job_id}), 201
@@ -197,6 +199,10 @@ def toggle_job(job_id):
             allowed['shuffle'] = 1 if allowed['shuffle'] else 0
         if 'show_chat' in allowed:
             allowed['show_chat'] = 1 if allowed['show_chat'] else 0
+        if 'transition_type' in allowed:
+            allowed['transition_type'] = str(allowed['transition_type'])
+        if 'transition_duration' in allowed:
+            allowed['transition_duration'] = int(allowed['transition_duration'] or 0)
         db.update_job(job_id, **allowed)
         sched.reload_jobs()
         return '', 204
@@ -285,6 +291,10 @@ def on_connect():
         'opacity':       int(state.get('clock_opacity',   '90')),
         'schedule_mode': state.get('clock_schedule_mode', 'always'),
     })
+    emit('transition_config', {
+        'type':     state.get('transition_type', 'fade'),
+        'duration': int(state.get('transition_duration', '600')),
+    })


 @socketio.on('admin_switch')
@@ -379,6 +389,27 @@ def toggle_addon(name):
     return jsonify({'name': name, 'enabled': new_val == '1'})


+@app.route('/api/transition', methods=['GET'])
+def get_transition():
+    state = db.get_state()
+    return jsonify({
+        'type':     state.get('transition_type', 'fade'),
+        'duration': int(state.get('transition_duration', '600')),
+    })
+
+
+@app.route('/api/transition', methods=['POST'])
+@login_required
+def set_transition():
+    data = request.get_json() or {}
+    t = data.get('type', 'fade')
+    d = int(data.get('duration', 600))
+    db.set_state('transition_type', t)
+    db.set_state('transition_duration', str(d))
+    socketio.emit('transition_config', {'type': t, 'duration': d})
+    return jsonify({'type': t, 'duration': d})
+
+
 if __name__ == '__main__':
     # Apply audio immediately (PipeWire is independent of the Wayland compositor)
     _apply_saved_audio()
diff --git a/kiosk/db.py b/kiosk/db.py
index d358655..bf75162 100644
--- a/kiosk/db.py
+++ b/kiosk/db.py
@@ -14,15 +14,17 @@ def init_db():
     with get_conn() as conn:
         conn.executescript("""
             CREATE TABLE IF NOT EXISTS jobs (
-                id       INTEGER PRIMARY KEY AUTOINCREMENT,
-                label    TEXT NOT NULL,
-                duration REAL NOT NULL,
-                type     TEXT NOT NULL,
-                source   TEXT NOT NULL,
-                enabled  INTEGER DEFAULT 1,
-                position INTEGER,
-                shuffle  INTEGER DEFAULT 0,
-                show_chat INTEGER DEFAULT 0
+                id                  INTEGER PRIMARY KEY AUTOINCREMENT,
+                label               TEXT NOT NULL,
+                duration            REAL NOT NULL,
+                type                TEXT NOT NULL,
+                source              TEXT NOT NULL,
+                enabled             INTEGER DEFAULT 1,
+                position            INTEGER,
+                shuffle             INTEGER DEFAULT 0,
+                show_chat           INTEGER DEFAULT 0,
+                transition_type     TEXT DEFAULT '',
+                transition_duration INTEGER DEFAULT 0
             );

             CREATE TABLE IF NOT EXISTS state (
@@ -42,6 +44,10 @@ def init_db():
             conn.execute("ALTER TABLE jobs ADD COLUMN shuffle INTEGER DEFAULT 0")
         if 'show_chat' not in cols:
             conn.execute("ALTER TABLE jobs ADD COLUMN show_chat INTEGER DEFAULT 0")
+        if 'transition_type' not in cols:
+            conn.execute("ALTER TABLE jobs ADD COLUMN transition_type TEXT DEFAULT ''")
+        if 'transition_duration' not in cols:
+            conn.execute("ALTER TABLE jobs ADD COLUMN transition_duration INTEGER DEFAULT 0")

         # Migrering: add position column if missing
         if 'position' not in cols:
@@ -92,14 +98,17 @@ def get_enabled_jobs():
         ).fetchall()]


-def add_job(label, duration, type_, source, shuffle=False, show_chat=False):
+def add_job(label, duration, type_, source, shuffle=False, show_chat=False,
+            transition_type='', transition_duration=0):
     with get_conn() as conn:
-        # determine next position
         curpos = conn.execute("SELECT MAX(position) AS m FROM jobs").fetchone()
         nextpos = (curpos['m'] or 0) + 1
         cur = conn.execute(
-            "INSERT INTO jobs (label, duration, type, source, position, shuffle, show_chat) VALUES (?, ?, ?, ?, ?, ?, ?)",
-            (label, duration, type_, source, nextpos, 1 if shuffle else 0, 1 if show_chat else 0)
+            "INSERT INTO jobs (label, duration, type, source, position, shuffle, show_chat, "
+            "transition_type, transition_duration) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+            (label, duration, type_, source, nextpos,
+             1 if shuffle else 0, 1 if show_chat else 0,
+             transition_type or '', int(transition_duration or 0))
         )
         return cur.lastrowid

@@ -125,7 +134,8 @@ def update_job(job_id, **fields):
     keys = []
     vals = []
     for k, v in fields.items():
-        if k in ('label', 'duration', 'type', 'source', 'enabled', 'shuffle', 'show_chat'):
+        if k in ('label', 'duration', 'type', 'source', 'enabled', 'shuffle', 'show_chat',
+                 'transition_type', 'transition_duration'):
             keys.append(f"{k} = ?")
             vals.append(v)
     if not keys:
diff --git a/kiosk/scheduler.py b/kiosk/scheduler.py
index 6b58673..d6be30f 100644
--- a/kiosk/scheduler.py
+++ b/kiosk/scheduler.py
@@ -40,8 +40,13 @@ def _advance():
 def _emit_and_schedule(job):
     if _socketio:
         _socketio.emit('switch', {
-            'type': job['type'], 'source': job['source'], 'scheduled': True,
-            'shuffle': bool(job.get('shuffle')), 'show_chat': bool(job.get('show_chat')),
+            'type':                job['type'],
+            'source':              job['source'],
+            'scheduled':           True,
+            'shuffle':             bool(job.get('shuffle')),
+            'show_chat':           bool(job.get('show_chat')),
+            'transition_type':     job.get('transition_type') or '',
+            'transition_duration': int(job.get('transition_duration') or 0),
         })
         db.set_current(job['type'], job['source'])
     scheduler.add_job(
diff --git a/kiosk/static/admin.js b/kiosk/static/admin.js
index 0f22885..ec72561 100644
--- a/kiosk/static/admin.js
+++ b/kiosk/static/admin.js
@@ -332,13 +332,16 @@ async function laggTillJobb(e) {
     source = '/chat-display';
   }

+  const txDur = form.transition_duration ? parseInt(form.transition_duration.value, 10) : 0;
   const data = {
-    label:     form.label.value,
-    duration:  form.duration.value,
+    label:               form.label.value,
+    duration:            form.duration.value,
     type,
     source,
-    show_chat: form.show_chat ? form.show_chat.checked : false,
-    shuffle:   form.shuffle   ? form.shuffle.checked   : false,
+    show_chat:           form.show_chat ? form.show_chat.checked : false,
+    shuffle:             form.shuffle   ? form.shuffle.checked   : false,
+    transition_type:     form.transition_type ? form.transition_type.value : '',
+    transition_duration: isNaN(txDur) ? 0 : txDur,
   };
   const res = await apiFetch('/api/jobs', {
     method: 'POST',
@@ -366,6 +369,8 @@ function openEdit(id) {
     form.type.value = job.type;
     form.source.value = job.source;
     if (form.shuffle) form.shuffle.checked = !!job.shuffle;
+    if (form.transition_type) form.transition_type.value = job.transition_type || '';
+    if (form.transition_duration) form.transition_duration.value = job.transition_duration || '';
     _jobTypeChanged('edit-form', 'edit-extract-wrap', 'edit-extract-status');
     modal.style.display = 'flex';
   });
@@ -383,12 +388,15 @@ async function saveEdit(e) {
   e.preventDefault();
   const form = e.target;
   const id = parseInt(form.id.value, 10);
+  const txDur = form.transition_duration ? parseInt(form.transition_duration.value, 10) : 0;
   const payload = {
-    label:    form.label.value,
-    duration: parseFloat(form.duration.value),
-    type:     form.type.value,
-    source:   form.source.value,
-    shuffle:  form.shuffle ? form.shuffle.checked : false,
+    label:               form.label.value,
+    duration:            parseFloat(form.duration.value),
+    type:                form.type.value,
+    source:              form.source.value,
+    shuffle:             form.shuffle ? form.shuffle.checked : false,
+    transition_type:     form.transition_type ? form.transition_type.value : '',
+    transition_duration: isNaN(txDur) ? 0 : txDur,
   };
   const res = await apiFetch('/api/jobs/' + id, {
     method: 'PATCH',
@@ -1593,6 +1601,38 @@ function escHtml(str) {
     .replace(/"/g, '&quot;');
 }

+// --- Övergångar ---
+
+async function laddaTransition() {
+  try {
+    const res = await apiFetch('/api/transition');
+    if (!res) return;
+    const d = await res.json();
+    const sel = document.getElementById('tx-type');
+    const dur = document.getElementById('tx-duration');
+    if (sel) sel.value = d.type || 'fade';
+    if (dur) dur.value = d.duration || 600;
+  } catch {}
+}
+
+async function saveTransition() {
+  const type     = document.getElementById('tx-type').value;
+  const duration = parseInt(document.getElementById('tx-duration').value, 10) || 600;
+  const status   = document.getElementById('tx-status');
+  try {
+    const res = await apiFetch('/api/transition', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ type, duration }),
+    });
+    if (!res) return;
+    status.textContent = 'Sparat!';
+    setTimeout(() => { status.textContent = ''; }, 2000);
+  } catch (e) {
+    status.textContent = 'Fel: ' + e.message;
+  }
+}
+
 // --- Init ---

 async function init() {
@@ -1609,6 +1649,7 @@ async function init() {
   await laddaBluetooth();
   await laddaWifiSaved();
   await laddaSovlage();
+  await laddaTransition();
   setInterval(laddaSysinfo, 10000);

   const res = await apiFetch('/api/sysinfo');
diff --git a/kiosk/static/display.js b/kiosk/static/display.js
index 85ec562..6cfd5b7 100644
--- a/kiosk/static/display.js
+++ b/kiosk/static/display.js
@@ -593,11 +593,165 @@ socket.on('disconnect', () => {
   if (_ytSinglePlayer) { try { _ytSinglePlayer.destroy(); } catch {} _ytSinglePlayer = null; }
 });

-function showLayer(name) {
-  ['iframe', 'video', 'local', 'image', 'idle'].forEach(l => {
-    document.getElementById('layer-' + l).style.display = 'none';
+// === 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;
   });
-  document.getElementById('layer-' + name).style.display = 'block';
 }

 socket.on('switch', (data) => {
@@ -605,6 +759,11 @@ socket.on('switch', (data) => {
   _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);
@@ -617,65 +776,66 @@ socket.on('switch', (data) => {
     if (_vpl.player) { try { _vpl.player.stopVideo(); } catch {} }
   }

-  // Stop single YT player when switching away
-  if (_currentType === 'youtube' && data.type !== 'youtube') {
-    if (_ytSinglePlayer) { try { _ytSinglePlayer.destroy(); } catch {} _ytSinglePlayer = null; }
+  // 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;  // reset — sätts om när YT.Player bekräftar live
+  _currentIsYtLive  = false;
   _currentType      = data.type;

   if (data.type === 'kiosk_chat') {
-    document.getElementById('frame').src = window.location.origin + '/chat-display';
-    showLayer('iframe');
+    _ensureFrame().src = window.location.origin + '/chat-display';
+    showLayer('iframe', jobTx);

   } else if (data.type === 'url') {
-    document.getElementById('frame').src = data.source;
-    showLayer('iframe');
+    _ensureFrame().src = data.source;
+    showLayer('iframe', jobTx);

   } else if (data.type === 'youtube') {
     if (_currentYtId) {
-      showLayer('iframe');
+      showLayer('iframe', jobTx);
       _playYoutubeVideo(_currentYtId);
     } else {
-      document.getElementById('frame').src = data.source;
-      showLayer('iframe');
+      _ensureFrame().src = data.source;
+      showLayer('iframe', jobTx);
     }

   } else if (data.type === 'video') {
     const v = document.getElementById('video');
     v.src = data.source;
-    showLayer('video');
+    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');
+    showLayer('image', jobTx);

   } else if (data.type === 'youtube_playlist') {
     _vpl.shuffle = !!data.shuffle;
-    _startVideoPlaylist(data.source);
+    _startVideoPlaylist(data.source, jobTx);

   } else if (data.type === 'emulator') {
-    document.getElementById('frame').src =
-      '/emulator?rom=' + encodeURIComponent(data.source);
-    showLayer('iframe');
+    _ensureFrame().src = '/emulator?rom=' + encodeURIComponent(data.source);
+    showLayer('iframe', jobTx);

   } else if (data.type === 'pdf' || data.type === 'game') {
-    document.getElementById('frame').src = data.source;
-    showLayer('iframe');
+    _ensureFrame().src = data.source;
+    showLayer('iframe', jobTx);

   } else if (data.type === 'local') {
     const html = data.html || '';
     if (!html && !data.source) {
-      showLayer('idle');
+      showLayer('idle', jobTx);
     } else {
       document.getElementById('layer-local').innerHTML = html;
-      showLayer('local');
+      showLayer('local', jobTx);
     }
   }

@@ -792,9 +952,9 @@ function _playYoutubeVideo(videoId) {
   });
 }

-async function _startVideoPlaylist(url) {
+async function _startVideoPlaylist(url, txOverride) {
   _vpl.url = url;
-  showLayer('iframe');
+  showLayer('iframe', txOverride);

   // Försök hämta cachade IDs
   let ids = [];
@@ -808,7 +968,7 @@ async function _startVideoPlaylist(url) {

   if (!ids.length) {
     // Inte extraherat än — extrahera nu (kan ta ~10s)
-    document.getElementById('frame').src = '';
+    _ensureFrame().src = '';
     try {
       const r = await fetch('/api/playlist/extract', {
         method: 'POST',
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
index aa05fb5..91bc638 100644
--- a/kiosk/templates/admin.html
+++ b/kiosk/templates/admin.html
@@ -104,6 +104,23 @@
           <label style="display:flex;align-items:center;gap:.5rem;color:#aaa;font-size:.9rem">
             <input type="checkbox" name="show_chat"> Visa livechat (YouTube)
           </label>
+          <div style="grid-column:1/-1;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
+            <label style="color:#aaa;font-size:.9rem;white-space:nowrap">Övergång</label>
+            <select name="transition_type" style="flex:1;min-width:130px">
+              <option value="">Använd global</option>
+              <option value="none">Ingen (direkt)</option>
+              <option value="fade">Fade</option>
+              <option value="slide-left">Slide vänster</option>
+              <option value="slide-right">Slide höger</option>
+              <option value="slide-up">Slide upp</option>
+              <option value="slide-down">Slide ner</option>
+              <option value="zoom">Zoom</option>
+              <option value="wipe">Wipe</option>
+              <option value="blur">Blur/dissolve</option>
+              <option value="pixel">Pixel-effekt</option>
+            </select>
+            <input name="transition_duration" type="number" min="0" max="3000" step="50" placeholder="ms (tom=global)" style="width:120px">
+          </div>
           <button type="submit">Lägg till</button>
         </div>
       </form>
@@ -145,6 +162,23 @@
                   <button type="button" class="secondary" onclick="openEmuBrowser('edit-form')">Välj ROM-fil</button>
                   <span id="edit-emu-name" style="font-size:.8rem;color:#94a3b8;font-family:monospace"></span>
                 </div>
+                <div style="grid-column:1/-1;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
+                  <label style="color:#aaa;font-size:.9rem;white-space:nowrap">Övergång</label>
+                  <select name="transition_type" style="flex:1;min-width:130px">
+                    <option value="">Använd global</option>
+                    <option value="none">Ingen (direkt)</option>
+                    <option value="fade">Fade</option>
+                    <option value="slide-left">Slide vänster</option>
+                    <option value="slide-right">Slide höger</option>
+                    <option value="slide-up">Slide upp</option>
+                    <option value="slide-down">Slide ner</option>
+                    <option value="zoom">Zoom</option>
+                    <option value="wipe">Wipe</option>
+                    <option value="blur">Blur/dissolve</option>
+                    <option value="pixel">Pixel-effekt</option>
+                  </select>
+                  <input name="transition_duration" type="number" min="0" max="3000" step="50" placeholder="ms (tom=global)" style="width:120px">
+                </div>
               </div>
               <div style="margin-top:.6rem"><button type="submit">Spara</button></div>
             </form>
@@ -158,6 +192,32 @@
       {% include 'addons/' + addon.name + '/panel.html' ignore missing %}
     {% endfor %}

+    <!-- Sektion: Övergångar -->
+    <section>
+      <h2>Övergångar</h2>
+      <div class="form-row">
+        <label for="tx-type">Typ</label>
+        <select id="tx-type">
+          <option value="none">Ingen (direkt)</option>
+          <option value="fade">Fade</option>
+          <option value="slide-left">Slide vänster</option>
+          <option value="slide-right">Slide höger</option>
+          <option value="slide-up">Slide upp</option>
+          <option value="slide-down">Slide ner</option>
+          <option value="zoom">Zoom</option>
+          <option value="wipe">Wipe</option>
+          <option value="blur">Blur/dissolve</option>
+          <option value="pixel">Pixel-effekt</option>
+        </select>
+      </div>
+      <div class="form-row">
+        <label for="tx-duration">Längd (ms)</label>
+        <input type="number" id="tx-duration" min="100" max="3000" step="50" value="600">
+      </div>
+      <button onclick="saveTransition()">Spara övergång</button>
+      <span id="tx-status" style="margin-left:.75rem;font-size:.85rem;color:#6c757d;"></span>
+    </section>
+
     <!-- Sektion: Systeminfo -->
     <section>
       <h2>Systeminfo</h2>
diff --git a/kiosk/templates/display.html b/kiosk/templates/display.html
index 1130ee6..f9a77a3 100644
--- a/kiosk/templates/display.html
+++ b/kiosk/templates/display.html
@@ -138,6 +138,48 @@
       0%,100% { opacity: 1; }
       50%      { opacity: .3; }
     }
+
+    /* === Layer transitions === */
+    .layer { will-change: transform, opacity; }
+
+    /* Fade */
+    @keyframes tx-fade-in  { from { opacity: 0; } to { opacity: 1; } }
+    @keyframes tx-fade-out { from { opacity: 1; } to { opacity: 0; } }
+
+    /* Slide left — ny layer kommer från höger */
+    @keyframes tx-slide-left-in  { from { transform: translateX(100%); } to { transform: translateX(0); } }
+    @keyframes tx-slide-left-out { from { transform: translateX(0);    } to { transform: translateX(-100%); } }
+
+    /* Slide right — ny layer kommer från vänster */
+    @keyframes tx-slide-right-in  { from { transform: translateX(-100%); } to { transform: translateX(0); } }
+    @keyframes tx-slide-right-out { from { transform: translateX(0);     } to { transform: translateX(100%); } }
+
+    /* Slide up — ny layer kommer underifrån */
+    @keyframes tx-slide-up-in  { from { transform: translateY(100%); } to { transform: translateY(0); } }
+    @keyframes tx-slide-up-out { from { transform: translateY(0);    } to { transform: translateY(-100%); } }
+
+    /* Slide down — ny layer kommer uppifrån */
+    @keyframes tx-slide-down-in  { from { transform: translateY(-100%); } to { transform: translateY(0); } }
+    @keyframes tx-slide-down-out { from { transform: translateY(0);     } to { transform: translateY(100%); } }
+
+    /* Zoom */
+    @keyframes tx-zoom-in  { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1);    opacity: 1; } }
+    @keyframes tx-zoom-out { from { transform: scale(1);    opacity: 1; } to { transform: scale(1.15); opacity: 0; } }
+
+    /* Wipe — vänster till höger via clip-path */
+    @keyframes tx-wipe-in  { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0% 0 0); } }
+    @keyframes tx-wipe-out { from { opacity: 1; } to { opacity: 0; } }
+
+    /* Blur dissolve (pixel-liknande) */
+    @keyframes tx-blur-in  { from { filter: blur(24px) brightness(1.3); opacity: 0; } to { filter: blur(0) brightness(1); opacity: 1; } }
+    @keyframes tx-blur-out { from { filter: blur(0) brightness(1);      opacity: 1; } to { filter: blur(24px) brightness(0.5); opacity: 0; } }
+
+    /* Pixel canvas overlay */
+    #tx-pixel-canvas {
+      position: fixed; inset: 0; z-index: 99998;
+      pointer-events: none;
+      display: none;
+    }
   </style>
 </head>
 <body>
@@ -189,6 +231,9 @@
     <div id="clock-date"></div>
   </div>

+  <!-- Transition: pixel canvas overlay -->
+  <canvas id="tx-pixel-canvas"></canvas>
+
   <!-- Söv-overlay — täcker allt -->
   <div id="layer-sleep" style="display:none; position:fixed; inset:0; background:#000; z-index:99999;"></div>