450 lines
16 KiB
JavaScript
450 lines
16 KiB
JavaScript
// 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})`;
|
||
}
|