301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
// 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();
|
|
}
|