// views/recorder.js — Recorder-View im XY-Layout-Stil (links Recorder-Panel, rechts 3 Slots) import { drawCachedStaticLayer } from './static_layer.js'; export const id = 'recorder'; const FRAME = { left: 0, top: 70, right: 0, bottom: 0 }; const METER_SLOTS = 3; const METER_GAP = 8; const SLOT_GAP = 12; const METER_WIDTH = 420; const METER_SLOT_SHRINK = 24; const METER_PAD_TOP = 1; const METER_PAD_BOTTOM = -7; const METER_EXTRA_BOTTOM_PAD = 6; const PANEL_BG = 'rgba(5,5,11,0.75)'; const SLOT_BG = 'rgba(10,12,18,0.85)'; const BORDER = 'rgba(120, 170, 220, 0.7)'; function isExternalClient() { const host = String(globalThis?.location?.hostname || '').trim().toLowerCase(); return !!host && host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '[::1]'; } export function init() { return { staticLayers: new Map(), hudLayoutSig: '' }; } export function resize() {} export function destroy(state) { if (state) state.staticLayers = null; } export async function render(env, state) { if (!env || !env.ctx) return; const { ctx: g, rect, recorder, meters, config: CONFIG } = env; const slotsRaw = env.slots?.(id) || []; const slots = slotsRaw.filter((v) => v && v !== 'none'); const layout = computeLayout(rect, slots.length); drawCachedPlotShell(state, g, layout.plot, CONFIG); drawCachedRecorderPanel(state, g, layout.scope); if (CONFIG.RECORD_SHOW_RECORDER_AB === true) { drawRecorderAbIndicator(g, layout.scope, recorder); } if (slots.length) { await drawMeterPanel(state, g, layout.meter, slots, meters, CONFIG); } positionRecorderHud(state, env.recorderDom, layout.scope); } function computeLayout(rect, slotCount = METER_SLOTS) { const plotX = 0; const plotY = FRAME.top; const innerWidth = Math.max(200, rect.w - plotX - FRAME.right); const minPlotW = 200; const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0)); const panelSlotsWidth = computePanelSlotsWidth(innerWidth, n); let meterW = n > 0 ? Math.max(panelSlotsWidth, (n === 1 ? 160 : (n === 2 ? 280 : METER_WIDTH))) : 0; let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0); if (plotW < minPlotW) { plotW = minPlotW; meterW = n > 0 ? (innerWidth - plotW - METER_GAP) : 0; } if (n > 0) meterW = Math.max(meterW, panelSlotsWidth); if (plotW < 80) plotW = 80; const plotH = Math.max(180, rect.h - FRAME.top - FRAME.bottom); const plot = { x: plotX, y: plotY, w: plotW, h: plotH }; const scope = computeScopeBox(plot); const meter = { x: plot.x + plot.w + (n > 0 ? METER_GAP : 0), y: plot.y, w: meterW, h: plotH, }; return { plot, scope, meter }; } function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS) { const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0)); const avail = Math.max(1, canvasWidth); const totalGap = (n - 1) * SLOT_GAP; const usable = avail - totalGap; return Math.max(1, Math.floor(usable / n)); } function computePanelSlotsWidth(innerWidth, slotCount = METER_SLOTS) { const panelSlotWidth = 100; const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0)); return n > 0 ? (panelSlotWidth * n + (n - 1) * SLOT_GAP) : 0; } function computeScopeBox(plot) { // Maximiere die Recorder-Box im Plot (kein zusätzlicher Innenabstand) const padding = 0; const w = Math.max(1, plot.w - padding); const h = Math.max(1, plot.h - padding); const x = Math.round(plot.x + padding / 2); const y = Math.round(plot.y + padding / 2); const cx = x + w / 2; const cy = y + h / 2; return { x, y, w, h, cx, cy }; } function drawCachedPlotShell(state, g, plot, CONFIG = {}) { const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT) ? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT)) : 14; drawCachedStaticLayer(state, g, 'recorder-plot-shell', `${gutterL}`, plot, (cg) => { cg.fillStyle = PANEL_BG; cg.fillRect(0, 0, plot.w, plot.h); cg.save(); cg.translate(-plot.x, -plot.y); cg.strokeStyle = '#00e7ff'; cg.lineWidth = 2; cg.beginPath(); cg.moveTo(plot.x - gutterL, plot.y); cg.lineTo(plot.x + plot.w, plot.y); cg.stroke(); cg.beginPath(); cg.moveTo(plot.x - gutterL, plot.y + plot.h); cg.lineTo(plot.x + plot.w, plot.y + plot.h); cg.stroke(); cg.beginPath(); cg.moveTo(plot.x, plot.y); cg.lineTo(plot.x, plot.y + plot.h); cg.stroke(); cg.beginPath(); cg.moveTo(plot.x + plot.w, plot.y); cg.lineTo(plot.x + plot.w, plot.y + plot.h); cg.stroke(); cg.restore(); }); } function drawCachedRecorderPanel(state, g, box) { drawCachedStaticLayer(state, g, 'recorder-box-shell', 'frame', box, (cg) => { cg.fillStyle = 'rgba(7, 8, 15, 0.7)'; cg.fillRect(0, 0, box.w, box.h); cg.strokeStyle = '#00e7ff'; cg.lineWidth = 2; cg.strokeRect(0.5, 0.5, box.w - 1, box.h - 1); }); } async function drawMeterPanel(state, g, area, slots, meters, CONFIG) { const slotCount = Array.isArray(slots) ? slots.length : 0; if (!slotCount || !area || area.w <= 0 || area.h <= 0) return; drawCachedStaticLayer(state, g, 'recorder-meter-shell', `${slotCount}|${CONFIG?.PANEL_DIVIDERS_ENABLED ? 1 : 0}`, area, (cg) => { cg.fillStyle = PANEL_BG; cg.fillRect(0, 0, area.w, area.h); cg.strokeStyle = '#00e7ff'; cg.lineWidth = 2; cg.strokeRect(0.5, 0.5, area.w - 1, area.h - 1); if (CONFIG?.PANEL_DIVIDERS_ENABLED) { const gap = SLOT_GAP; const slotW = computePanelSlotWidth(area.w, slotCount); const totalSlotW = slotW * slotCount + gap * (slotCount - 1); const startX = Math.max(0, Math.floor((area.w - totalSlotW) / 2)); const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2; const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2; for (let i = 1; i < slotCount; i++) { const rectX = startX + i * (slotW + gap); const dividerX = rectX - gap / 2; cg.save(); cg.strokeStyle = 'rgba(0,231,255,0.4)'; cg.lineWidth = 1; cg.setLineDash([4, 3]); cg.beginPath(); cg.moveTo(dividerX, slotTopPadding - 6); cg.lineTo(dividerX, area.h - slotBottomPadding + 6); cg.stroke(); cg.restore(); } } }); g.save(); const gap = SLOT_GAP; const slotW = computePanelSlotWidth(area.w, slotCount); const totalSlotW = slotW * slotCount + gap * (slotCount - 1); const startX = area.x + Math.max(0, Math.floor((area.w - totalSlotW) / 2)); const slotTopPadding = METER_PAD_TOP + METER_SLOT_SHRINK / 2; const slotBottomPadding = METER_PAD_BOTTOM + METER_EXTRA_BOTTOM_PAD + METER_SLOT_SHRINK / 2; const slotH = Math.max(40, area.h - slotTopPadding - slotBottomPadding); for (let i = 0; i < slotCount; i++) { const x = startX + i * (slotW + gap); const rect = { x, y: area.y + slotTopPadding, w: slotW, h: slotH }; const slotId = slots[i]; if (slotId) { const shrink = Math.max(0, METER_SLOT_SHRINK); const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD); const innerOffset = shrink / 2; const innerRect = { x: rect.x, y: rect.y + METER_PAD_TOP + innerOffset, w: rect.w, h: innerHeight, }; try { await meters.draw(g, innerRect, slotId, CONFIG); } catch (e) { /* ignore draw errors to keep view alive */ } } } g.restore(); } function positionRecorderHud(state, dom = {}, scope) { if (!dom || !scope) return; const { hud, label, start, stop, auto, list, timer } = dom; if (!hud) return; const centerX = scope.x + scope.w / 2; const topY = scope.y + 8; // Buttons mittig im unteren Drittel const baseY = scope.y + scope.h - 24 - Math.max(start?.offsetHeight || 0, stop?.offsetHeight || 0, auto?.offsetHeight || 0); const gap = 20; const startW = (start?.offsetWidth || 120); const stopW = (stop?.offsetWidth || 120); const autoW = (auto?.offsetWidth || 120); const totalW = startW + gap + stopW + gap + autoW; const startX = centerX - totalW / 2; const listTop = topY + (label?.offsetHeight || 30) + 8; const listBottomReserve = 28; const listMaxHeight = list ? Math.max(72, ((timer ? (baseY - (timer.offsetHeight || 18) - 10) : baseY) - listTop - listBottomReserve)) : 0; const sig = [ scope.x, scope.y, scope.w, scope.h, label?.offsetWidth || 0, label?.offsetHeight || 0, start?.offsetWidth || 0, start?.offsetHeight || 0, stop?.offsetWidth || 0, stop?.offsetHeight || 0, auto?.offsetWidth || 0, auto?.offsetHeight || 0, timer?.offsetWidth || 0, timer?.offsetHeight || 0, listMaxHeight, ].join('|'); if (state?.hudLayoutSig === sig) return; if (state) state.hudLayoutSig = sig; if (label) { label.style.left = `${centerX - (label.offsetWidth || 0) / 2}px`; label.style.top = `${topY}px`; } if (list) { const y = topY + (label?.offsetHeight || 30) + 8; list.classList.toggle('rec-list--external', isExternalClient()); list.style.width = `${Math.max(200, scope.w - 45)}px`; list.style.left = `${scope.x + 12}px`; list.style.top = `${y}px`; list.style.maxHeight = `${listMaxHeight}px`; } if (start) { start.style.left = `${startX}px`; start.style.top = `${baseY}px`; } if (stop) { stop.style.left = `${startX + startW + gap}px`; stop.style.top = `${baseY}px`; } if (auto) { auto.style.left = `${startX + startW + gap + stopW + gap}px`; auto.style.top = `${baseY}px`; } if (timer) { const y = baseY - (timer.offsetHeight || 18) - 10; timer.style.left = `${centerX - (timer.offsetWidth || 80) / 2}px`; timer.style.top = `${y}px`; } } function drawRecorderAbIndicator(g, box, recorder = {}) { const active = recorder.activeRecorderSlot === 'A' || recorder.activeRecorderSlot === 'B' ? recorder.activeRecorderSlot : null; const processing = new Set(Array.isArray(recorder.processingRecorderSlots) ? recorder.processingRecorderSlots : []); const next = recorder.nextRecorderSlot === 'B' ? 'B' : 'A'; const colorFor = (slot) => { if (active === slot) return '#ff3b3b'; if (!processing.has(slot) && (active || next === slot)) return '#34d399'; if (processing.has(slot)) return '#8fd3d4'; return 'rgba(143, 211, 212, 0.45)'; }; const top = box.y + 12; const left = box.x + 14; const gap = 18; g.save(); g.font = 'bold 18px ui-monospace, monospace'; g.textAlign = 'left'; g.textBaseline = 'top'; g.fillStyle = colorFor('A'); g.fillText('A', left, top); g.fillStyle = colorFor('B'); g.fillText('B', left + gap, top); g.restore(); }