// core/screensaver.js — handles idle-activated screensaver overlay export function createScreensaver({ config, body = (typeof document !== 'undefined' ? document.body : null) } = {}) { const state = { active: false, force: false, lastSignalTs: now(), ball: { x: 120, y: 120, vx: 180, vy: 140, w: 140, h: 80, color: '#ff0080', tint: '#ff0080', hue: 0 }, logo: null, logoReady: false, enabled: true, mode: (config?.SCREENSAVER_MODE === 'starfield' || config?.SCREENSAVER_MODE === 'clock' || config?.SCREENSAVER_MODE === 'black') ? config.SCREENSAVER_MODE : 'clock', stars: [], }; function now() { return (typeof performance !== 'undefined' ? performance.now() : Date.now()); } function getThreshold() { const t = config?.SCREENSAVER_ACTIVITY_DB; return Number.isFinite(t) ? t : -50; } function markActivity(ts) { const t = ts || now(); state.lastSignalTs = t; state.force = false; if (state.active) setActive(false); } function markAudioActivity(levelDb, ts) { if (!Number.isFinite(levelDb)) return false; if (levelDb > getThreshold()) { // Pegel-Trigger nur, wenn kein forcierter Testlauf aktiv ist if (!state.force) { markActivity(ts); } return true; } return false; } function ensureLogo() { if (state.logo !== null) return; const img = new Image(); img.src = 'assets/dvdlogo.svg'; img.onload = () => { state.logoReady = true; }; img.onerror = () => { state.logoReady = false; }; state.logo = img; } function setActive(next) { state.active = !!next; if (body) { body.classList.toggle('screensaver-active', state.active); } } function nextLogoColor(prev = '#ff0080') { const palette = ['#ff0080', '#00d8ff', '#ffd400', '#00ff88', '#ff6600', '#9b59ff', '#ff2d55']; let c = prev; for (let i = 0; i < 4 && c === prev; i++) { c = palette[Math.floor(Math.random() * palette.length)]; } return c; } function randomizeBall(rect) { const { ball } = state; if (!rect) return; const base = Math.min(rect.w, rect.h) * 0.22; if (state.logo && state.logoReady && state.logo.width && state.logo.height) { const aspect = state.logo.width / state.logo.height; ball.w = base; ball.h = base / aspect; } else { ball.w = base; ball.h = base * 0.55; } const halfW = ball.w / 2; const halfH = ball.h / 2; ball.x = rect.x + halfW + Math.random() * Math.max(1, rect.w - 2 * halfW); ball.y = rect.y + halfH + Math.random() * Math.max(1, rect.h - 2 * halfH); const speed = 160 + Math.random() * 80; const angle = Math.random() * Math.PI * 2; ball.vx = Math.cos(angle) * speed; ball.vy = Math.sin(angle) * speed; ball.color = nextLogoColor(); ball.tint = ball.color; } function updateBall(rect, dtMs) { const { ball } = state; const dt = Math.max(0.001, dtMs / 1000); ball.x += ball.vx * dt; ball.y += ball.vy * dt; const halfW = ball.w / 2; const halfH = ball.h / 2; const minX = rect.x + halfW; const maxX = rect.x + rect.w - halfW; const minY = rect.y + halfH; const maxY = rect.y + rect.h - halfH; let bounced = false; if (ball.x < minX) { ball.x = minX; ball.vx *= -1; bounced = true; } if (ball.x > maxX) { ball.x = maxX; ball.vx *= -1; bounced = true; } if (ball.y < minY) { ball.y = minY; ball.vy *= -1; bounced = true; } if (ball.y > maxY) { ball.y = maxY; ball.vy *= -1; bounced = true; } if (bounced) { const nxt = nextLogoColor(ball.color); ball.color = nxt; ball.tint = nxt; ball.hue = Math.floor(Math.random() * 360); } } function draw(ctx, rect) { if (state.mode === 'starfield') { drawStarfield(ctx, rect); return; } const { ball } = state; ctx.save(); ctx.fillStyle = 'rgba(5,5,10,0.9)'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); const radius = Math.min(ball.w, ball.h) * 0.18; const x = ball.x - ball.w / 2; const y = ball.y - ball.h / 2; const col = ball.color || '#ff0080'; // Logo body ctx.fillStyle = col; ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + ball.w - radius, y); ctx.quadraticCurveTo(x + ball.w, y, x + ball.w, y + radius); ctx.lineTo(x + ball.w, y + ball.h - radius); ctx.quadraticCurveTo(x + ball.w, y + ball.h, x + ball.w - radius, y + ball.h); ctx.lineTo(x + radius, y + ball.h); ctx.quadraticCurveTo(x, y + ball.h, x, y + ball.h - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); ctx.fill(); ctx.stroke(); // DVD text ctx.fillStyle = '#000'; ctx.font = `${Math.max(18, ball.h * 0.4)}px 'Arial Black', ui-sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('DVD', ball.x, ball.y); ctx.restore(); } function updateAndRender(ctx, rect, nowTs, dtMs) { const enabled = state.enabled !== false && config?.SCREENSAVER_ENABLED !== false; if (!enabled && !state.force) { if (state.active) setActive(false); return false; } const idleMin = Math.max(0, Number(config?.SCREENSAVER_IDLE_MIN ?? 0)); const idleMs = idleMin * 60 * 1000; const shouldActivate = state.force || (idleMs > 0 && (nowTs - state.lastSignalTs) >= idleMs); if (shouldActivate && !state.active) { randomizeBall(rect); setActive(true); } else if (!shouldActivate && state.active) { setActive(false); } if (!state.active) return false; if (state.mode === 'starfield') { updateStars(rect, dtMs); drawStarfield(ctx, rect); return true; } if (state.mode === 'black') { ctx.save(); ctx.fillStyle = 'black'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.restore(); return true; } if (state.mode === 'clock') { drawClock(ctx, rect, nowTs); return true; } updateBall(rect, dtMs); drawDvd(ctx, rect); return true; } function ensureStars(rect, count = 160) { if (!rect) return; if (!state.stars || state.stars.length !== count) { state.stars = new Array(count).fill(0).map(() => makeStar(rect)); } } function makeStar(rect) { const angle = Math.random() * Math.PI * 2; const speed = 0.15 + Math.random() * 0.25; const hue = 180 + Math.random() * 120; return { x: (Math.random() * 2 - 1) * rect.w * 0.35, y: (Math.random() * 2 - 1) * rect.h * 0.35, z: Math.random() * 1 + 0.3, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, hue, }; } function updateStars(rect, dtMs) { ensureStars(rect); const dt = Math.max(0.001, dtMs / 1000); for (let i = 0; i < state.stars.length; i++) { const s = state.stars[i]; s.x += s.vx * rect.w * dt; s.y += s.vy * rect.h * dt; s.z -= dt * 0.35; if (s.z <= 0.05 || Math.abs(s.x) > rect.w || Math.abs(s.y) > rect.h) { state.stars[i] = makeStar(rect); } } } function drawStarfield(ctx, rect) { ensureStars(rect); ctx.save(); ctx.fillStyle = 'rgba(3,4,8,0.92)'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h / 2; for (const s of state.stars) { const scale = 280 / (s.z * rect.w); const x = cx + s.x * scale; const y = cy + s.y * scale; const len = Math.max(4, 18 * (1 - s.z)); const dx = (s.x * scale) * 0.02; const dy = (s.y * scale) * 0.02; ctx.strokeStyle = `hsl(${s.hue}, 90%, 65%)`; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x - dx, y - dy); ctx.lineTo(x + dx, y + dy); ctx.stroke(); ctx.fillStyle = `hsl(${s.hue}, 90%, 70%)`; ctx.beginPath(); ctx.arc(x, y, Math.max(1, len * 0.08), 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } function drawDvd(ctx, rect) { ensureLogo(); const { ball } = state; ctx.save(); ctx.fillStyle = 'rgba(5,5,10,0.9)'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); if (state.logo && state.logoReady) { const x = ball.x - ball.w / 2; const y = ball.y - ball.h / 2; ctx.filter = `hue-rotate(${ball.hue || 0}deg)`; ctx.drawImage(state.logo, x, y, ball.w, ball.h); ctx.filter = 'none'; } else { // Fallback: simples Logo const radius = Math.min(ball.w, ball.h) * 0.18; const x = ball.x - ball.w / 2; const y = ball.y - ball.h / 2; const col = ball.color || '#ff0080'; ctx.fillStyle = col; ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + ball.w - radius, y); ctx.quadraticCurveTo(x + ball.w, y, x + ball.w, y + radius); ctx.lineTo(x + ball.w, y + ball.h - radius); ctx.quadraticCurveTo(x + ball.w, y + ball.h, x + ball.w - radius, y + ball.h); ctx.lineTo(x + radius, y + ball.h); ctx.quadraticCurveTo(x, y + ball.h, x, y + ball.h - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.fillStyle = '#000'; ctx.font = `${Math.max(18, ball.h * 0.4)}px 'Arial Black', ui-sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('DVD', ball.x, ball.y); } ctx.restore(); } function drawClock(ctx, rect, nowTs) { const d = new Date(); const hours = d.getHours(); const mins = d.getMinutes(); const secs = d.getSeconds() + d.getMilliseconds() / 1000; const centerX = rect.x + rect.w / 2; const centerY = rect.y + rect.h / 2; const radius = Math.min(rect.w, rect.h) * 0.48; const baseDot = Math.max(1.5, Math.round(radius * 0.03)); const ringDot = baseDot * 0.7; const glyphDot = baseDot * 1.3; ctx.save(); ctx.fillStyle = 'rgba(8,8,12,0.9)'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.translate(centerX, centerY); // seconds ring + 5s markers for (let i = 0; i < 60; i++) { const ang = (Math.PI * 2 * i) / 60 - Math.PI / 2; const r = radius * 0.88; const filled = i <= secs; const cx = Math.cos(ang) * r; const cy = Math.sin(ang) * r; const glow = config?.SCREENSAVER_LED_GLOW !== false; const color = normalizeColor(config?.SCREENSAVER_LED_COLOR); if (filled) drawLed(ctx, cx, cy, ringDot, glow, color); else { ctx.fillStyle = 'rgba(120,120,120,0.25)'; ctx.beginPath(); ctx.arc(cx, cy, ringDot, 0, Math.PI * 2); ctx.fill(); } if (i % 5 === 0) { const outerR = r + ringDot * 3.2; const ocx = Math.cos(ang) * outerR; const ocy = Math.sin(ang) * outerR; drawLed(ctx, ocx, ocy, ringDot, glow, color); } } // center time (HH:MM) as dot-matrix const timeStr = `${pad2(hours)}:${pad2(mins)}`; const glyphSize = radius * 0.32; const timeSpacing = 0.9; const timeY = -(glyphSize * 1.2) / 2; // vertikal zentriert auf der Mittelachse const blinkOn = isSecondPulseActive(d); const extraGaps = [0, 3]; // zusätzliche Spalte zwischen den Ziffern in jedem Block const metrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps); const timeX = -metrics.colonCenter; // Doppelpunkt auf Mittelpunkt legen const glow = config?.SCREENSAVER_LED_GLOW !== false; const color = normalizeColor(config?.SCREENSAVER_LED_COLOR); drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot * 1.1, timeSpacing, blinkOn ? (glyphDot * 1.1) : 0, extraGaps, glow, color); ctx.restore(); } function drawClockDigital(ctx, rect) { const d = new Date(); const hours = d.getHours(); const mins = d.getMinutes(); const secs = d.getSeconds() + d.getMilliseconds() / 1000; const day = d.getDate(); const month = d.getMonth() + 1; const year = d.getFullYear(); const centerX = rect.x + rect.w / 2; const centerY = rect.y + rect.h / 2; const radius = Math.min(rect.w, rect.h) * 0.45; const baseDot = Math.max(1.5, Math.round(radius * 0.028)); const glyphDot = baseDot * 1.3; const glyphSize = radius * 0.32; const timeSpacing = 0.9; const blinkOn = isSecondPulseActive(d); const extraGaps = [0, 3]; const color = normalizeColor(config?.SCREENSAVER_LED_COLOR); const glow = config?.SCREENSAVER_LED_GLOW !== false; ctx.save(); ctx.fillStyle = 'rgba(8,8,12,0.9)'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.translate(centerX, centerY); const timeStr = `${pad2(hours)}:${pad2(mins)}:${pad2(Math.floor(secs))}`; const dateStr = `${pad2(day)}.${pad2(month)}.${year}`; const dateSize = glyphSize * 0.8; const dateGaps = [1, 4]; // nach DD und MM const timeMetrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps); const dateMetrics = measureTextLayout(dateStr, dateSize, timeSpacing, dateGaps); const totalH = glyphSize * 1.2 + dateSize * 1.2 + glyphSize * 0.25; const startY = -totalH / 2; const timeX = -timeMetrics.colonCenter; const timeY = startY; drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot, timeSpacing, blinkOn ? glyphDot : 0, extraGaps, glow, color); const dateX = -dateMetrics.width / 2; const dateY = startY + glyphSize * 1.2 + glyphSize * 0.25; drawDotText(ctx, dateStr, dateX, dateY, dateSize, glyphDot * 0.9, timeSpacing, 0, dateGaps, glow, color); ctx.restore(); } return { markActivity, markAudioActivity, updateAndRender, triggerTest() { state.force = true; state.lastSignalTs = now(); }, setIdleMinutes(min) { if (Number.isFinite(min)) config.SCREENSAVER_IDLE_MIN = min; }, setActivityThreshold(db) { if (Number.isFinite(db)) config.SCREENSAVER_ACTIVITY_DB = db; }, setEnabled(val) { state.enabled = !!val; config.SCREENSAVER_ENABLED = state.enabled; if (!state.enabled && state.active) setActive(false); }, setColor(val) { if (typeof val === 'string' && val.trim()) { config.SCREENSAVER_LED_COLOR = val; } }, setMode(mode) { const m = mode === 'starfield' ? 'starfield' : (mode === 'clock' ? 'clock' : (mode === 'black' ? 'black' : (mode === 'lines' ? 'lines' : 'dvd'))); state.mode = m; config.SCREENSAVER_MODE = m; if (m === 'starfield') { state.stars = []; } else if (m === 'clock') { // no special init needed } else if (m === 'lines') { state.lines = []; } else if (m === 'dvd') { randomizeBall({ x: 0, y: 0, w: 1, h: 1 }); } }, isEnabled: () => state.enabled !== false && config?.SCREENSAVER_ENABLED !== false, isActive: () => state.active, }; } // --- Dot glyph rendering for clock ----------------------------------------- const DOT_FONT = { '0': [ '01110', '10001', '10011', '10101', '11001', '10001', '01110', ], '1': [ '00100', '01100', '00100', '00100', '00100', '00100', '01110', ], '2': [ '01110', '10001', '00001', '00110', '01000', '10000', '11111', ], '3': [ '11110', '00001', '00001', '01110', '00001', '00001', '11110', ], '4': [ '00010', '00110', '01010', '10010', '11111', '00010', '00010', ], '5': [ '11111', '10000', '11110', '00001', '00001', '10001', '01110', ], '6': [ '00110', '01000', '10000', '11110', '10001', '10001', '01110', ], '7': [ '11111', '00001', '00010', '00100', '01000', '01000', '01000', ], '8': [ '01110', '10001', '10001', '01110', '10001', '10001', '01110', ], '9': [ '01110', '10001', '10001', '01111', '00001', '00010', '01100', ], ':': [ '0', '1', '0', '0', '1', '0', '0', ], }; const DOT_COLS = DOT_FONT['0'][0].length; function drawDotText(ctx, text, x, y, glyphSize, dotRadius, spacingFactor = 0.9, colonDotRadiusOverride = null, extraGapIndices = [], glow = true, color = 'rgb(255,0,0)') { let cursorX = x; const glyphW = glyphSize; const glyphH = glyphSize * 1.2; for (let i = 0; i < text.length; i++) { const ch = text[i]; const useDot = (ch === ':' && colonDotRadiusOverride !== null) ? colonDotRadiusOverride : dotRadius; drawDotChar(ctx, ch, cursorX, y, glyphW, glyphH, useDot, glow, color); cursorX += glyphW * spacingFactor; if (extraGapIndices.includes(i)) { cursorX += glyphW / DOT_COLS; } } } function measureTextLayout(text, glyphSize, spacingFactor, extraGapIndices = []) { const glyphW = glyphSize; const colonIndex = text.indexOf(':'); let cursor = 0; let colonCenter = 0; for (let i = 0; i < text.length; i++) { if (i === colonIndex) { colonCenter = cursor + glyphW * 0.5; } cursor += glyphW * spacingFactor; if (extraGapIndices.includes(i)) cursor += glyphW / DOT_COLS; } return { width: cursor, colonCenter }; } function drawDotChar(ctx, ch, x, y, w, h, dotRadius, glow = true, color = 'rgb(255,0,0)') { const rows = DOT_FONT[ch] || DOT_FONT['0']; const cols = rows[0].length; const r = Math.min(dotRadius, Math.max(1, Math.min(w, h) * 0.04)); const cellW = w / cols; const cellH = h / rows.length; for (let row = 0; row < rows.length; row++) { for (let col = 0; col < cols; col++) { if (rows[row][col] === '1') { const cx = x + col * cellW + cellW / 2; const cy = y + row * cellH + cellH / 2; drawLed(ctx, cx, cy, r, glow, color); } } } } function drawLed(ctx, cx, cy, r, glow = true, color = 'rgb(255,0,0)') { if (r <= 0) return; if (glow) { const haloR = r * 2.2; const midR = r * 1.4; ctx.fillStyle = colorWithAlpha(color, 0.08); ctx.beginPath(); ctx.arc(cx, cy, haloR, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = colorWithAlpha(color, 0.35); ctx.beginPath(); ctx.arc(cx, cy, midR, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = color; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); } function pad2(n) { return n < 10 ? `0${n}` : String(n); } function isSecondPulseActive(date, pulseMs = 500) { return date.getMilliseconds() < pulseMs; } function normalizeColor(val) { if (typeof val !== 'string' || !val.trim()) return '#ff0000'; return val.trim(); } function colorWithAlpha(color, alpha) { const c = normalizeColor(color); if (/^#([0-9a-fA-F]{6})$/.test(c)) { const r = parseInt(c.slice(1, 3), 16); const g = parseInt(c.slice(3, 5), 16); const b = parseInt(c.slice(5, 7), 16); return `rgba(${r},${g},${b},${alpha})`; } if (/^rgb\(/i.test(c)) { const nums = c.match(/(\d+\.?\d*)/g)?.slice(0, 3) || [255, 0, 0]; return `rgba(${nums[0]},${nums[1]},${nums[2]},${alpha})`; } return `rgba(255,0,0,${alpha})`; }