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, '"');
}
+// --- Ö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>