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