Files
2026-06-02 20:56:19 +02:00

450 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// views/clock.js — Vollbild-Studiosclock mit "Zurück"-Button
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'],
'.': ['000','000','000','000','000','000','010'],
};
const DOT_COLS = DOT_FONT['0'][0].length;
const DIN_SCALE = [
{ db: -50, pos: 0.0205 },
{ db: -40, pos: 0.0564 },
{ db: -35, pos: 0.0974 },
{ db: -30, pos: 0.1538 },
{ db: -25, pos: 0.2308 },
{ db: -20, pos: 0.3179 },
{ db: -15, pos: 0.4359 },
{ db: -10, pos: 0.5538 },
{ db: -5, pos: 0.7026 },
{ db: 0, pos: 0.8513 },
{ db: +5, pos: 1.0000 },
];
export const id = 'clock';
export function init(env) {
const btn = document.createElement('button');
btn.textContent = 'Vollbild';
btn.className = 'btn btn-compact';
Object.assign(btn.style, {
position: 'fixed',
top: '10px',
right: '10px',
zIndex: 12000,
padding: '4px 8px',
cursor: 'pointer',
});
btn.onclick = () => {
toggleFullscreen(env, btn);
};
document.body.appendChild(btn);
return { btn, fullscreen: false };
}
export function destroy(state) {
if (state?.btn && state.btn.parentNode) state.btn.parentNode.removeChild(state.btn);
}
export function resize() {}
export async function render(env, state) {
const { ctx: g, rect } = env;
const styleCfg = env.config?.CLOCK_STYLE;
if (styleCfg === 'digital') {
drawClockDigital(g, rect, env, { showPpm: false });
} else if (styleCfg === 'digital-ppm') {
drawClockDigital(g, rect, env, { showPpm: true });
} else if (styleCfg === 'analog-ppm') {
drawClockAnalog(g, rect, env, { showPpm: true });
} else {
drawClockAnalog(g, rect, env, { showPpm: false });
}
}
function toggleFullscreen(env, btn) {
const hud = document.querySelector('.hud');
const options = document.getElementById('optionsPanelNew');
const isFs = btn.dataset.fs === '1';
if (isFs) {
if (hud) hud.style.display = '';
if (options) options.style.display = (env.style === 'options-panel') ? 'block' : 'none';
btn.textContent = 'Vollbild';
btn.dataset.fs = '0';
} else {
if (hud) hud.style.display = 'none';
if (options) options.style.display = 'none';
btn.textContent = '↩';
btn.dataset.fs = '1';
}
}
function drawClockAnalog(ctx, rect, env, opts = {}) {
const showPpm = !!opts.showPpm;
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;
const glow = env.config?.CLOCK_LED_GLOW !== false;
const color = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
ctx.save();
ctx.fillStyle = 'rgba(8,8,12,1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.translate(centerX, centerY);
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;
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;
drawLed(ctx, Math.cos(ang) * outerR, Math.sin(ang) * outerR, ringDot, glow, color);
}
}
const timeStr = `${pad2(hours)}:${pad2(mins)}`;
const glyphSize = radius * 0.32;
const timeSpacing = 0.9; // wie Screensaver
const timeY = -(glyphSize * 1.2) / 2; // vertikal zentriert
const blinkOn = isSecondPulseActive(d);
const extraGaps = [0, 3]; // wie Screensaver (Luft zwischen Blöcken)
const metrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
const timeX = -(metrics.colonCenter ?? metrics.width / 2); // Doppelpunkt auf Mitte
drawDotText(ctx, timeStr, timeX, timeY, glyphSize, glyphDot * 1.1, timeSpacing, blinkOn ? (glyphDot * 1.1) : 0, extraGaps, glow, color);
if (showPpm) {
const ppmWidth = radius * 1.2;
const ppmY = glyphSize * 1.0; // etwas mehr Abstand zur Uhrzeit
const ppmLevels = {
L: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
R: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
};
drawMiniPpm(ctx, { cx: 0, cy: ppmY }, ppmWidth, glyphDot, env, ppmLevels);
}
ctx.restore();
}
function drawClockDigital(ctx, rect, env, opts = {}) {
const showPpm = !!opts.showPpm;
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.48;
const baseDot = Math.max(1.5, Math.round(radius * 0.028));
const glyphDot = baseDot * 1.3;
const glyphSize = radius * 0.32;
const timeSpacing = 1.2;
const blinkOn = isSecondPulseActive(d);
const extraGaps = [0, 3, 6]; // nach HH, MM, SS
const color = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
const glow = env.config?.CLOCK_LED_GLOW !== false;
ctx.save();
ctx.fillStyle = 'rgba(8,8,12,1)';
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.7;
const dateSpacing = 1.1;
const dateGaps = [1, 4];
const timeMetrics = measureTextLayout(timeStr, glyphSize, timeSpacing, extraGaps);
const dateMetrics = measureTextLayout(dateStr, dateSize, dateSpacing, dateGaps);
const lineGap = glyphSize * 0.6;
const totalH = glyphSize * 1.2 + dateSize * 1.2 + lineGap;
const startY = -totalH / 2;
const timeX = -timeMetrics.width / 2;
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 + lineGap;
drawDotText(ctx, dateStr, dateX, dateY, dateSize, glyphDot * 0.9, dateSpacing, null, dateGaps, glow, color, dateSize * 0.8);
if (showPpm) {
const ppmLevels = {
L: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
R: mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
};
const ppmWidth = dateMetrics.width;
const dateBottom = dateY + dateSize * 1.2;
const bottom = rect.h / 2;
const ppmY = dateBottom + (bottom - dateBottom) * 0.5;
drawMiniPpm(ctx, { cx: 0, cy: ppmY }, ppmWidth, glyphDot, env, ppmLevels);
}
ctx.restore();
}
function drawMiniPpm(ctx, pos, width, dotSize, env, levels) {
const normalCol = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
const warnCol = normalizeColor(env.config?.CLOCK_LED_COLOR || '#ff0000');
const bgCol = 'rgba(255,255,255,0.08)';
// Feste Farbe wie die %Beschriftung im PPM-DIN-Meter, etwas dunkler
const tickCol = '#ff9922';
const minDb = DIN_SCALE[0].db;
const maxDb = DIN_SCALE[DIN_SCALE.length - 1].db;
const lvl = [
Number.isFinite(levels?.L) ? levels.L : mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinL) ? env.audio.ppmDinL : env.audio?.ppmL, env.config),
Number.isFinite(levels?.R) ? levels.R : mapPpmRawToDin(Number.isFinite(env.audio?.ppmDinR) ? env.audio.ppmDinR : env.audio?.ppmR, env.config),
];
const h = Math.max(4, dotSize * 1.4);
const gap = h * 0.6;
const halfW = width / 2;
const ticks = [-50, -40, -30, -20, -10, -5, 0, +5];
const norm = (db) => {
const clamped = Math.min(maxDb, Math.max(minDb, db));
for (let i = 0; i < DIN_SCALE.length - 1; i++) {
const a = DIN_SCALE[i];
const b = DIN_SCALE[i + 1];
if (clamped >= a.db && clamped <= b.db) {
const t = (clamped - a.db) / (b.db - a.db || 1);
return a.pos + t * (b.pos - a.pos);
}
}
return clamped >= maxDb ? DIN_SCALE[DIN_SCALE.length - 1].pos : DIN_SCALE[0].pos;
};
ctx.save();
ctx.translate(pos.cx, pos.cy);
for (let ch = 0; ch < 2; ch++) {
const y = (ch === 0 ? - (h + gap) * 0.5 : (h + gap) * 0.5);
ctx.fillStyle = bgCol;
ctx.fillRect(-halfW, y - h / 2, width, h);
const val = norm(lvl[ch]);
const fillW = width * val;
const over = lvl[ch] > 0;
const colNorm = normalCol;
const colWarn = normalizeColor('#ff5050');
const norm0 = norm(0);
const warnStartX = -halfW + norm0 * width;
const ledMode = env.config?.CLOCK_PPM_LED_MODE === true;
const drawTicksAndLabels = () => {
ctx.fillStyle = tickCol;
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
ticks.forEach((t) => {
const x = -halfW + norm(t) * width;
const tickLen = (ch === 1) ? h * 1.8 : h * 1.2; // unterer Balken: längere Ticks
ctx.fillRect(x - 0.5, y - h * 0.6, 1, tickLen);
if (ch === 1) {
ctx.textAlign = (t === -50 ? 'right' : 'left');
ctx.textBaseline = 'bottom';
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
ctx.fillText(String(t), labelX, y + tickLen + h * 0.15);
}
});
};
if (ledMode) {
// Nur untere Label (ch==1) zeichnen, keine Tick-Striche über den LEDs
if (ch === 1) {
ticks.forEach((t) => {
const x = -halfW + norm(t) * width;
const tickLen = h * 1.8;
const labelY = y + tickLen + h * 0.15;
// Verbindungsstrich nur unterhalb des Meters (bis zum Meter-Anfang)
const lineTop = y + h * 0.5;
ctx.fillStyle = tickCol;
ctx.fillRect(x - 0.5, lineTop, 1, Math.max(0, labelY - lineTop));
ctx.fillStyle = tickCol;
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
ctx.textAlign = (t === -50 ? 'right' : 'left');
ctx.textBaseline = 'bottom';
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
ctx.fillText(String(t), labelX, labelY);
});
}
const step = dotSize * 1.6;
const dotR = dotSize * 0.55;
for (let x = -halfW; x <= -halfW + fillW; x += step) {
const isWarn = x >= warnStartX;
const col = isWarn ? colWarn : colNorm;
drawLed(ctx, x, y, dotR, env.config?.CLOCK_LED_GLOW !== false, col);
}
} else {
// Norm-Anteil
const normW = over ? Math.max(0, warnStartX - (-halfW)) : fillW;
if (normW > 0) {
const glowH = h * 1.1;
ctx.fillStyle = colorWithAlpha(colNorm, 0.12);
ctx.fillRect(-halfW, y - glowH / 2, normW, glowH);
ctx.fillStyle = colorWithAlpha(colNorm, 0.08);
ctx.fillRect(-halfW, y - glowH, normW, glowH * 2);
ctx.fillStyle = colNorm;
ctx.fillRect(-halfW, y - h / 2, normW, h);
}
// Warn-Anteil
if (over) {
const warnW = Math.max(0, fillW - (warnStartX - (-halfW)));
if (warnW > 0) {
const glowH = h * 1.1;
ctx.fillStyle = colorWithAlpha(colWarn, 0.15);
ctx.fillRect(warnStartX, y - glowH / 2, warnW, glowH);
ctx.fillStyle = colorWithAlpha(colWarn, 0.1);
ctx.fillRect(warnStartX, y - glowH, warnW, glowH * 2);
ctx.fillStyle = colWarn;
ctx.fillRect(warnStartX, y - h / 2, warnW, h);
}
}
// Ticks/Labels obenauf im Balken-Modus (Farben wie PPM-DIN Prozent-Marks)
ctx.save();
const tickColOver = colorWithAlpha(tickCol, ledMode ? 1.0 : 0.6);
ctx.fillStyle = tickColOver;
ctx.font = `${Math.max(5, h * 0.8)}px ui-monospace, monospace`;
ticks.forEach((t) => {
const x = -halfW + norm(t) * width;
const tickLen = (ch === 1) ? h * 1.8 : h * 1.2; // unterer Balken: längere Ticks
ctx.fillRect(x - 0.5, y - h * 0.6, 1, tickLen);
if (ch === 1) {
ctx.textAlign = (t === -50 ? 'right' : 'left');
ctx.textBaseline = 'bottom';
const labelX = t === -50 ? (x - h * 0.2) : (x + h * 0.2);
ctx.fillText(String(t), labelX, y + tickLen + h * 0.15);
}
});
ctx.restore();
}
}
ctx.restore();
}
function mapPpmRawToDin(raw, cfg) {
const minDb = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : DIN_SCALE[0].db;
const maxDb = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : DIN_SCALE[DIN_SCALE.length - 1].db;
if (!Number.isFinite(raw)) return minDb;
const base = Number.isFinite(cfg?.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? cfg.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15;
const effOff = (cfg?.PPM_DIN_MODE === 'al_minus6' ? -6 : -9) + (Number(cfg?.PPM_DIN_TRIM_DB) || 0);
const mapped = (raw - base) + effOff;
return Math.max(minDb, Math.min(maxDb, mapped));
}
function drawDotText(ctx, text, x, y, glyphSize, dotRadius, spacingFactor = 0.9, colonDotRadiusOverride = null, extraGapIndices = [], glow = true, color = 'rgb(255,0,0)', glyphHeightOverride = null) {
let cursorX = x;
const glyphW = glyphSize;
const glyphH = (glyphHeightOverride || 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;
let cursor = 0;
let colonCenter = null;
for (let i = 0; i < text.length; i++) {
if (text[i] === ':') colonCenter = cursor + glyphW * 0.5;
cursor += glyphW * spacingFactor;
if (extraGapIndices.includes(i)) cursor += glyphW / DOT_COLS;
}
if (colonCenter === null) colonCenter = cursor / 2;
return { width: cursor, colonCenter };
}
function drawDotChar(ctx, ch, x, y, w, h, dotRadius, glow = true, color = 'rgb(255,0,0)') {
if (!dotRadius) return;
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 = '#ff0000') {
if (r <= 0) return;
if (glow) {
const haloR = r * 2.2;
const midR = r * 1.4;
const col = normalizeColor(color);
ctx.fillStyle = colorWithAlpha(col, 0.08);
ctx.beginPath();
ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = colorWithAlpha(col, 0.35);
ctx.beginPath();
ctx.arc(cx, cy, midR, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = normalizeColor(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})`;
}