Files
Phoenix/www/core/screensaver.js
T
2026-06-02 20:56:19 +02:00

643 lines
19 KiB
JavaScript

// 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})`;
}