2741 lines
94 KiB
JavaScript
2741 lines
94 KiB
JavaScript
// main.js — App-Bootstrap: Env bauen, Views/Audio/Options verdrahten, Loop fahren
|
||
|
||
// --- One-time migration: enforce new dBFS range [+9 .. -63]
|
||
try {
|
||
const k2 = 'MIGRATE_DBFS_BOUNDS_9__63';
|
||
|
||
// --- Sanity: guard AXIS_GUTTER_LEFT to avoid layout/NaN issues
|
||
try {
|
||
const k = 'MIGRATE_SANITY_GUTTER';
|
||
if (!localStorage.getItem(k)) {
|
||
const g = Number(CONFIG && CONFIG.AXIS_GUTTER_LEFT);
|
||
if (!Number.isFinite(g) || g < 8) CONFIG.AXIS_GUTTER_LEFT = 14;
|
||
localStorage.setItem(k, '1');
|
||
}
|
||
} catch(_) {}
|
||
if (!localStorage.getItem(k2)) {
|
||
if (typeof CONFIG?.DBFS_TOP === 'number' && CONFIG.DBFS_TOP !== 9) CONFIG.DBFS_TOP = 9;
|
||
if (typeof CONFIG?.DBFS_BOTTOM === 'number' && CONFIG.DBFS_BOTTOM !== -63) CONFIG.DBFS_BOTTOM = -63;
|
||
localStorage.setItem(k2, '1');
|
||
}
|
||
} catch(_) {}
|
||
|
||
import { CONFIG, loadConfig, saveConfig, loadLayoutPreset } from './core/config.js';
|
||
import * as utils from './core/utils.js';
|
||
import { meterFacade, registerMeter } from './core/registry.js';
|
||
import { initAudio, audioLost, reloadAudio } from './core/audio.js';
|
||
import { createScreensaver } from './core/screensaver.js';
|
||
|
||
// Views
|
||
import * as viewGoni from './views/goniometer_rtw.js';
|
||
import * as viewPhaseWheel from './views/phase_wheel.js';
|
||
import * as viewPanel from './views/panel.js';
|
||
import * as viewRealtime from './views/realtime.js';
|
||
import * as viewClassicNeedles from './views/classic_needles.js';
|
||
import * as viewPeakHistory from './views/peak_history.js';
|
||
import * as viewSpectrogram from './views/spectrogram.js';
|
||
import * as viewWaveform from './views/waveform.js';
|
||
import * as viewRecorder from './views/recorder.js';
|
||
import * as viewSplit from './views/split_view.js';
|
||
import * as viewQuad from './views/quad_view.js';
|
||
|
||
// Meters
|
||
import * as vu from './meters/vu.js';
|
||
import * as ppmEbu from './meters/ppm_ebu.js';
|
||
import * as ppmDin from './meters/ppm_din.js';
|
||
import * as tp from './meters/tp.js';
|
||
import * as hifiPeak from './meters/hifi_peak.js';
|
||
import * as rms from './meters/rms.js';
|
||
import * as lufs from './meters/lufs.js';
|
||
import * as stopwatch from './meters/stopwatch.js';
|
||
|
||
// Options UI
|
||
import { setupOptions } from './ui/options.js';
|
||
|
||
// --- Canvas & Context ---------------------------------------------------------
|
||
const C = document.getElementById('cv');
|
||
const g = C.getContext('2d');
|
||
|
||
function resizeCanvas() {
|
||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||
const width = innerWidth * dpr;
|
||
const height = innerHeight * dpr;
|
||
C.width = width;
|
||
C.height = height;
|
||
C.style.width = `${innerWidth}px`;
|
||
C.style.height = `${innerHeight}px`;
|
||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
g.textBaseline = 'alphabetic';
|
||
g.font = 'bold 14px ui-monospace, monospace';
|
||
g.lineWidth = 1.6;
|
||
}
|
||
addEventListener('resize', resizeCanvas);
|
||
resizeCanvas();
|
||
|
||
// --- HUD ---------------------------------------------------------------------
|
||
const styleSel = document.getElementById('styleSel');
|
||
const slotWrap = document.getElementById('slotWrap');
|
||
const slotLabel = document.getElementById('slotLabel');
|
||
const slotSel1 = document.getElementById('slotSel1');
|
||
const slotSel2 = document.getElementById('slotSel2');
|
||
const slotSel3 = document.getElementById('slotSel3');
|
||
const slotSel4 = document.getElementById('slotSel4');
|
||
const slotSel5 = document.getElementById('slotSel5');
|
||
const slotSelects = [slotSel1, slotSel2, slotSel3, slotSel4, slotSel5];
|
||
const peakHistScrollWrap = document.getElementById('peakHistScrollWrap');
|
||
const peakHistScrollSel = document.getElementById('peakHistScrollSel');
|
||
const goniAgcToggle = document.getElementById('goniAgcToggle');
|
||
const goniAgcChk = document.getElementById('goniAgcChk');
|
||
const spectroLegendWrap = document.getElementById('spectroLegendWrap');
|
||
const waveWindowWrap = document.getElementById('waveWindowWrap');
|
||
const waveWindowSel = document.getElementById('waveWindowSel');
|
||
const waveModeWrap = document.getElementById('waveModeWrap');
|
||
const waveModeSel = document.getElementById('waveModeSel');
|
||
const rtaBpoWrap = document.getElementById('rtaBpoWrap');
|
||
const rtaBpoSel = document.getElementById('rtaBpoSel');
|
||
const splitViewWrap = document.getElementById('splitViewWrap');
|
||
const splitLeftSel = document.getElementById('splitLeftSel');
|
||
const splitRightSel = document.getElementById('splitRightSel');
|
||
const splitMeterBtnWrap = document.getElementById('splitMeterBtnWrap');
|
||
const splitMeterBtn = document.getElementById('splitMeterBtn');
|
||
const splitMeterPopup = document.getElementById('splitMeterPopup');
|
||
const splitMeterPopupTitle = document.getElementById('splitMeterPopupTitle');
|
||
const splitMeterPopupClose = document.getElementById('splitMeterPopupClose');
|
||
const splitMeterRow1 = document.getElementById('splitMeterRow1');
|
||
const splitMeterRow2 = document.getElementById('splitMeterRow2');
|
||
const splitMeterRow3 = document.getElementById('splitMeterRow3');
|
||
const splitMeterSel1 = document.getElementById('splitMeterSel1');
|
||
const splitMeterSel2 = document.getElementById('splitMeterSel2');
|
||
const splitMeterSel3 = document.getElementById('splitMeterSel3');
|
||
const quadViewWrap = document.getElementById('quadViewWrap');
|
||
const quadPlotBtn = document.getElementById('quadPlotBtn');
|
||
const quadMeterBtnWrap = document.getElementById('quadMeterBtnWrap');
|
||
const quadMeterBtn = document.getElementById('quadMeterBtn');
|
||
const quadPlotPopup = document.getElementById('quadPlotPopup');
|
||
const quadPlotPopupClose = document.getElementById('quadPlotPopupClose');
|
||
const quadPlotSelTL = document.getElementById('quadPlotSelTL');
|
||
const quadPlotSelTR = document.getElementById('quadPlotSelTR');
|
||
const quadPlotSelBL = document.getElementById('quadPlotSelBL');
|
||
const quadPlotSelBR = document.getElementById('quadPlotSelBR');
|
||
const gainWrap = document.getElementById('gainWrap');
|
||
const gainSel = document.getElementById('gainSel');
|
||
const resetPeakBtn= document.getElementById('resetPeakBtn');
|
||
const phaseTrailToggle = document.getElementById('phaseTrailToggle');
|
||
const phaseTrailChk = document.getElementById('phaseTrailChk');
|
||
const recorderHud = document.getElementById('recorderHud');
|
||
const recTargetSel = document.getElementById('recTargetSel');
|
||
const recStartBtn = document.getElementById('recStartBtn');
|
||
const recStopBtn = document.getElementById('recStopBtn');
|
||
const recAutoBtn = document.getElementById('recAutoBtn');
|
||
const recLabel = document.getElementById('recLabel');
|
||
const recList = document.getElementById('recList');
|
||
const recTimer = document.getElementById('recTimer');
|
||
const recProcessing = document.getElementById('recProcessing');
|
||
const recWarning = document.getElementById('recWarning');
|
||
const recWarningAck = document.getElementById('recWarningAck');
|
||
const recWarningBtn = document.getElementById('recWarningBtn');
|
||
const calibrationNotice = document.getElementById('calibrationNotice');
|
||
const calibrationNoticeBtn = document.getElementById('calibrationNoticeBtn');
|
||
const CALIBRATION_NOTICE_KEY = 'calibration_notice_seen_v1';
|
||
const REC_WARNING_KEY = 'recorder_warning_ack_v1';
|
||
let calibrationNoticeSeen = false;
|
||
try {
|
||
calibrationNoticeSeen = localStorage.getItem(CALIBRATION_NOTICE_KEY) === '1';
|
||
} catch (_) {}
|
||
if (!calibrationNoticeSeen) {
|
||
try { calibrationNoticeSeen = sessionStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {}
|
||
}
|
||
const recorderState = {
|
||
status: 'idle',
|
||
target: null,
|
||
error: '',
|
||
startTs: 0,
|
||
lastDurationMs: 0,
|
||
history: [],
|
||
timerRaf: null,
|
||
activeSession: null,
|
||
processingJobs: 0,
|
||
nextSessionId: 1,
|
||
nextRecorderSlot: 'A',
|
||
activeRecorderSlot: null,
|
||
processingRecorderSlots: [],
|
||
autoEnabled: false,
|
||
autoMonitorRaf: null,
|
||
autoSilenceSince: 0,
|
||
autoPendingStart: false,
|
||
autoPendingStop: false,
|
||
};
|
||
const RECORDER_HISTORY_LIMIT = 100;
|
||
|
||
function getRecorderAutoSplitGapMs() {
|
||
const raw = Number(CONFIG.RECORD_AUTO_SPLIT_GAP_SEC);
|
||
const sec = Math.max(0.5, Math.min(3, Number.isFinite(raw) ? raw : 1.5));
|
||
return sec * 1000;
|
||
}
|
||
|
||
function getRecorderAutoThresholdDbfs() {
|
||
const raw = Number(CONFIG.RECORD_AUTO_THRESHOLD_DBFS);
|
||
return Math.max(-120, Math.min(20, Number.isFinite(raw) ? raw : -50));
|
||
}
|
||
const optionsPanelNew = document.getElementById('optionsPanelNew');
|
||
const errBox = document.getElementById('err');
|
||
|
||
const REALTIME_GAIN_CHOICES = [-10, -5, 0, 3, 15, 20, 25];
|
||
const WAVE_WINDOW_CHOICES = [0.05, 0.1, 0.5, 1, 2, 5, 10, 15];
|
||
const WAVE_MODE_CHOICES = [
|
||
['stacked', 'Gestapelt'],
|
||
['overlay', 'Überlagert'],
|
||
['diff', 'Differenz (L−R)'],
|
||
];
|
||
const RTA_BPO_CHOICES = [
|
||
['1_3', '1/3 Oktave'],
|
||
['1_6', '1/6 Oktave'],
|
||
['1_12', '1/12 Oktave'],
|
||
];
|
||
const PEAK_HISTORY_SCROLL_CHOICES = [0.5, 1, 2, 4, 6];
|
||
const RECORDER_TARGETS = [
|
||
{ id: 'download', label: 'Download auf diesem Rechner' },
|
||
{ id: 'analyzer', label: 'Pi: /home/analyzer/Aufnahmen' },
|
||
{ id: 'volumio', label: 'Pi: /home/volumio/Aufnahmen' },
|
||
];
|
||
if (!recorderState.target) {
|
||
recorderState.target = CONFIG.RECORD_TARGET || RECORDER_TARGETS[0]?.id || 'download';
|
||
}
|
||
const GONIO_GAIN_MIN_DB = -35;
|
||
const GONIO_GAIN_MAX_DB = 35;
|
||
if (recTargetSel) {
|
||
recTargetSel.innerHTML = '';
|
||
RECORDER_TARGETS.forEach((t) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = t.id;
|
||
opt.textContent = t.label;
|
||
recTargetSel.appendChild(opt);
|
||
});
|
||
recorderState.target = CONFIG.RECORD_TARGET || RECORDER_TARGETS[0]?.id || 'download';
|
||
recTargetSel.disabled = false;
|
||
}
|
||
function renderRecordingList() {
|
||
if (!recList) return;
|
||
recList.innerHTML = '';
|
||
const title = document.createElement('h4');
|
||
title.textContent = 'Aufnahmen';
|
||
recList.appendChild(title);
|
||
if (!recorderState.history || !recorderState.history.length) {
|
||
const empty = document.createElement('div');
|
||
empty.textContent = 'Keine Aufnahmen';
|
||
recList.appendChild(empty);
|
||
return;
|
||
}
|
||
recorderState.history.forEach((entry) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'rec-item';
|
||
const name = document.createElement('div');
|
||
name.className = 'rec-name';
|
||
name.textContent = entry.name;
|
||
const actions = document.createElement('div');
|
||
actions.style.display = 'flex';
|
||
actions.style.gap = '6px';
|
||
const btnSave = document.createElement('button');
|
||
btnSave.className = 'btn btn-compact';
|
||
btnSave.textContent = entry.saving
|
||
? 'Speichert…'
|
||
: (entry.saved
|
||
? 'Gespeichert'
|
||
: (entry.released
|
||
? 'Download gestartet'
|
||
: (entry.downloadRequested ? 'Erneut laden' : 'Speichern')));
|
||
btnSave.disabled = !!entry.saving || !!entry.saved || !!entry.released;
|
||
btnSave.onclick = () => {
|
||
void saveRecordingEntry(entry);
|
||
};
|
||
const btnDel = document.createElement('button');
|
||
btnDel.className = 'btn btn-compact';
|
||
btnDel.textContent = 'Löschen';
|
||
btnDel.onclick = () => {
|
||
removeRecordingFromHistory(entry);
|
||
};
|
||
actions.appendChild(btnSave);
|
||
actions.appendChild(btnDel);
|
||
row.appendChild(name);
|
||
row.appendChild(actions);
|
||
recList.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function downloadRecordingEntry(entry, { rerender = true } = {}) {
|
||
if (!entry?.url) return false;
|
||
const a = document.createElement('a');
|
||
a.href = entry.url;
|
||
a.download = entry.name || 'recording.webm';
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
entry.downloadRequested = true;
|
||
if (rerender) renderRecordingList();
|
||
return true;
|
||
}
|
||
|
||
function releaseRecordingEntryBlob(entry) {
|
||
if (!entry?.url) return false;
|
||
try { URL.revokeObjectURL(entry.url); } catch (_) {}
|
||
entry.url = '';
|
||
entry.released = true;
|
||
return true;
|
||
}
|
||
|
||
function isPhoenixRecorderSaveAvailable() {
|
||
return String(env?.audio?.backendMode || 'phoenix') === 'phoenix' && !!env?.audio?.baseUrl;
|
||
}
|
||
|
||
function getRecorderSaveTarget() {
|
||
const raw = CONFIG.RECORD_TARGET || recorderState.target || 'download';
|
||
return (raw === 'analyzer' || raw === 'volumio') ? raw : 'download';
|
||
}
|
||
|
||
async function saveRecordingEntryToPhoenix(entry) {
|
||
if (!entry?.url) throw new Error('Keine Aufnahme vorhanden');
|
||
const baseUrl = String(env?.audio?.baseUrl || '').trim();
|
||
if (!baseUrl) throw new Error('Phoenix nicht verbunden');
|
||
const target = getRecorderSaveTarget();
|
||
if (target !== 'analyzer' && target !== 'volumio') {
|
||
throw new Error('Ungültiges Phoenix-Ziel');
|
||
}
|
||
const filename = encodeURIComponent(String(entry.name || 'recording.wav'));
|
||
const blob = await fetch(entry.url).then(async (res) => {
|
||
if (!res.ok) throw new Error(`Blob-Zugriff fehlgeschlagen (${res.status})`);
|
||
return await res.blob();
|
||
});
|
||
const res = await fetch(`${baseUrl}/api/v1/recordings/save/${target}/${filename}`, {
|
||
method: 'POST',
|
||
cache: 'no-store',
|
||
headers: { 'content-type': blob.type || 'audio/wav' },
|
||
body: blob,
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`Speichern fehlgeschlagen (${res.status})`);
|
||
}
|
||
return await res.json();
|
||
}
|
||
|
||
async function saveRecordingEntry(entry, { rerender = true } = {}) {
|
||
if (!entry || entry.saving || entry.saved) return false;
|
||
entry.saving = true;
|
||
if (rerender) renderRecordingList();
|
||
try {
|
||
if (getRecorderSaveTarget() !== 'download') {
|
||
if (!isPhoenixRecorderSaveAvailable()) {
|
||
throw new Error('Phoenix-Speicherweg ist aktuell nicht verfügbar');
|
||
}
|
||
const result = await saveRecordingEntryToPhoenix(entry);
|
||
entry.saved = true;
|
||
entry.savedPath = result?.path || '';
|
||
releaseRecordingEntryBlob(entry);
|
||
} else {
|
||
const downloaded = downloadRecordingEntry(entry, { rerender: false });
|
||
if (!downloaded) return false;
|
||
entry.saved = true;
|
||
releaseRecordingEntryBlob(entry);
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
recorderState.error = err?.message || 'Speichern fehlgeschlagen';
|
||
showErr(`Recorder: ${recorderState.error}`);
|
||
return false;
|
||
} finally {
|
||
entry.saving = false;
|
||
if (rerender) renderRecordingList();
|
||
}
|
||
}
|
||
if (gainSel) {
|
||
gainSel.innerHTML = '';
|
||
REALTIME_GAIN_CHOICES.forEach((db) => {
|
||
const opt = document.createElement('option');
|
||
const label = `${db > 0 ? '+' : ''}${db} dB`;
|
||
opt.value = String(db);
|
||
opt.textContent = label;
|
||
gainSel.appendChild(opt);
|
||
});
|
||
}
|
||
if (waveWindowSel) {
|
||
waveWindowSel.innerHTML = '';
|
||
WAVE_WINDOW_CHOICES.forEach((sec) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(sec);
|
||
opt.textContent = formatWaveWindowLabel(sec);
|
||
waveWindowSel.appendChild(opt);
|
||
});
|
||
}
|
||
if (waveModeSel) {
|
||
waveModeSel.innerHTML = '';
|
||
WAVE_MODE_CHOICES.forEach(([val, label]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = val;
|
||
opt.textContent = label;
|
||
waveModeSel.appendChild(opt);
|
||
});
|
||
}
|
||
if (rtaBpoSel) {
|
||
rtaBpoSel.innerHTML = '';
|
||
RTA_BPO_CHOICES.forEach(([val, label]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = val;
|
||
opt.textContent = label;
|
||
rtaBpoSel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function showErr(msg){
|
||
if (!msg) return;
|
||
console.error('App Error:', msg);
|
||
errBox.textContent = String(msg);
|
||
errBox.style.display = 'block';
|
||
setTimeout(hideErr, 5000);
|
||
}
|
||
function hideErr(){ errBox.style.display = 'none'; }
|
||
|
||
function getPersistentItem(key) {
|
||
try {
|
||
const v = localStorage.getItem(key);
|
||
if (v !== null && v !== undefined) return v;
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
function setPersistentItem(key, value) {
|
||
const str = String(value ?? '');
|
||
try { localStorage.setItem(key, str); } catch (_) {}
|
||
}
|
||
|
||
// Persistenz
|
||
loadConfig();
|
||
const STYLE_STORAGE_KEY = 'analyzer_style';
|
||
const LAST_NON_OPTION_STYLE_KEY = 'analyzer_last_non_option_style';
|
||
const SLOT_FALLBACK_ID = 'vu';
|
||
const SLOT_OPTIONS = [
|
||
['vu','VU'],
|
||
['ppm-ebu','PPM (EBU Type IIb)'],
|
||
['ppm-din','PPM (DIN Type I)'],
|
||
['tp','True Peak'],
|
||
['hifi-peak','HiFi Peak'],
|
||
['rms','True RMS'],
|
||
['lufs','LUFS (EBU R128)'],
|
||
['stopwatch','Stoppuhr'],
|
||
['none','(leer)'],
|
||
];
|
||
const SLOT_VALUE_SET = new Set(SLOT_OPTIONS.map(([val]) => val));
|
||
const SLOT_STORAGE_KEY = 'analyzer_slots_v2';
|
||
const SLOT_STORAGE_VIEW_PREFIX = `${SLOT_STORAGE_KEY}__`;
|
||
const SLOT_CUSTOMIZED_KEY = 'analyzer_slots_customized_v1';
|
||
const LEGACY_SLOT_KEY = 'analyzer_slots';
|
||
const VIEWS_DISALLOW_EMPTY_SLOT = new Set(['peak-history', 'classic-needles']);
|
||
const VIEW_SLOT_COUNTS = {
|
||
'realtime': 1,
|
||
'classic-needles': 1,
|
||
'peak-history': 1,
|
||
'goniometer-rtw': 3,
|
||
'phase-wheel': 3,
|
||
'panel': 5,
|
||
'spectrogram': 1,
|
||
'waveform': 1,
|
||
'recorder': 3,
|
||
'split-view': 0,
|
||
'quad-view': 0,
|
||
'clock': 0,
|
||
};
|
||
const VIEW_SLOT_DEFAULTS = {
|
||
'realtime': ['ppm-din'],
|
||
'classic-needles': ['vu'],
|
||
'peak-history': ['ppm-din'],
|
||
'goniometer-rtw': ['ppm-ebu', 'ppm-din', 'lufs'],
|
||
'phase-wheel': ['vu', 'ppm-ebu', 'tp'],
|
||
'panel': ['vu', 'ppm-ebu', 'ppm-din', 'tp', 'rms'],
|
||
'spectrogram': ['ppm-din'],
|
||
'waveform': ['ppm-din'],
|
||
'recorder': ['ppm-ebu', 'ppm-din', 'rms'],
|
||
'split-view': [],
|
||
'quad-view': [],
|
||
'clock': [],
|
||
};
|
||
const CONFIG_BACKED_GLOBAL_SLOTS = Object.freeze({
|
||
'peak-history': 'PEAK_HISTORY_SLOT',
|
||
'classic-needles': 'CLASSIC_NEEDLES_SLOT',
|
||
});
|
||
function resolveStyleId(viewId){
|
||
return (viewId in VIEW_SLOT_DEFAULTS) ? viewId : 'panel';
|
||
}
|
||
function sanitizeSlotId(id){
|
||
if (id === 'ppm') return 'ppm-ebu';
|
||
return SLOT_VALUE_SET.has(id) ? id : null;
|
||
}
|
||
function normalizeSlots(viewId, arr){
|
||
const resolved = resolveStyleId(viewId);
|
||
const need = (resolved in VIEW_SLOT_COUNTS) ? VIEW_SLOT_COUNTS[resolved] : 1;
|
||
const defaults = (resolved in VIEW_SLOT_DEFAULTS) ? VIEW_SLOT_DEFAULTS[resolved] : VIEW_SLOT_DEFAULTS.panel;
|
||
const disallowEmpty = VIEWS_DISALLOW_EMPTY_SLOT.has(resolved);
|
||
const out = [];
|
||
for (let i = 0; i < need; i++){
|
||
let desired = Array.isArray(arr) ? sanitizeSlotId(arr[i]) : null;
|
||
if (disallowEmpty && desired === 'none') desired = null;
|
||
out[i] = desired ?? defaults[i] ?? defaults[0] ?? SLOT_FALLBACK_ID;
|
||
}
|
||
return out;
|
||
}
|
||
function loadLegacySlots(){
|
||
try {
|
||
const raw = getPersistentItem(LEGACY_SLOT_KEY);
|
||
if (!raw) return null;
|
||
const arr = JSON.parse(raw);
|
||
if (!Array.isArray(arr)) return null;
|
||
const legacy = {};
|
||
for (const key of Object.keys(VIEW_SLOT_DEFAULTS)){
|
||
legacy[key] = normalizeSlots(key, arr);
|
||
}
|
||
return legacy;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
function saveSlotState(state){
|
||
try { setPersistentItem(SLOT_STORAGE_KEY, JSON.stringify(state)); } catch (_) {}
|
||
// Also persist per-view to be resilient against any single corrupted value.
|
||
try {
|
||
for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) {
|
||
setPersistentItem(`${SLOT_STORAGE_VIEW_PREFIX}${key}`, JSON.stringify(state?.[key] || []));
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function loadSlotCustomizedState() {
|
||
try {
|
||
const raw = getPersistentItem(SLOT_CUSTOMIZED_KEY);
|
||
if (!raw) return {};
|
||
const parsed = JSON.parse(raw);
|
||
return (parsed && typeof parsed === 'object') ? parsed : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function saveSlotCustomizedState(state) {
|
||
try { setPersistentItem(SLOT_CUSTOMIZED_KEY, JSON.stringify(state)); } catch (_) {}
|
||
}
|
||
|
||
function loadSlotState(){
|
||
const readPersistentJson = (key) => {
|
||
const candidates = [];
|
||
try { candidates.push(localStorage.getItem(key)); } catch (_) {}
|
||
for (const raw of candidates) {
|
||
if (!raw) continue;
|
||
try { return JSON.parse(raw); } catch (_) {}
|
||
}
|
||
return null;
|
||
};
|
||
const loadPerViewSlots = () => {
|
||
const out = {};
|
||
let any = false;
|
||
for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) {
|
||
const parsed = readPersistentJson(`${SLOT_STORAGE_VIEW_PREFIX}${key}`);
|
||
if (Array.isArray(parsed)) {
|
||
out[key] = parsed;
|
||
any = true;
|
||
}
|
||
}
|
||
return any ? out : null;
|
||
};
|
||
|
||
let source = readPersistentJson(SLOT_STORAGE_KEY);
|
||
if (!(source && typeof source === 'object')) source = null;
|
||
if (!source) source = loadPerViewSlots();
|
||
if (!source) source = loadLegacySlots();
|
||
if (!source) source = {};
|
||
const state = {};
|
||
const customized = loadSlotCustomizedState();
|
||
for (const key of Object.keys(VIEW_SLOT_DEFAULTS)){
|
||
state[key] = normalizeSlots(key, source?.[key]);
|
||
}
|
||
|
||
// Classic-Needles soll standardmäßig immer mit VU starten und erst nach manueller Änderung
|
||
// den zuletzt gewählten Slot beibehalten.
|
||
if (customized['classic-needles'] !== true) {
|
||
state['classic-needles'] = normalizeSlots('classic-needles', VIEW_SLOT_DEFAULTS['classic-needles']);
|
||
}
|
||
|
||
saveSlotState(state);
|
||
return state;
|
||
}
|
||
|
||
function syncConfigBackedSlots() {
|
||
let changed = false;
|
||
for (const [viewId, configKey] of Object.entries(CONFIG_BACKED_GLOBAL_SLOTS)) {
|
||
const fallback = (VIEW_SLOT_DEFAULTS[viewId] && VIEW_SLOT_DEFAULTS[viewId][0]) || SLOT_FALLBACK_ID;
|
||
const desired = sanitizeSlotId(CONFIG?.[configKey]);
|
||
const normalized = normalizeSlots(viewId, [desired && desired !== 'none' ? desired : fallback]);
|
||
const current = normalizeSlots(viewId, slotsState?.[viewId]);
|
||
if (current[0] !== normalized[0]) {
|
||
slotsState[viewId] = normalized;
|
||
changed = true;
|
||
}
|
||
CONFIG[configKey] = normalized[0] || fallback;
|
||
}
|
||
if (changed) {
|
||
saveSlotState(slotsState);
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
let slotsState = loadSlotState();
|
||
syncConfigBackedSlots();
|
||
const DEFAULT_STYLE = 'realtime';
|
||
const storedStyleRaw = getPersistentItem(STYLE_STORAGE_KEY);
|
||
const cfgStyleRaw = (typeof CONFIG?.UI_LAST_STYLE === 'string' && CONFIG.UI_LAST_STYLE) ? CONFIG.UI_LAST_STYLE : null;
|
||
let style = storedStyleRaw || cfgStyleRaw || DEFAULT_STYLE;
|
||
if (style === 'options') style = 'options-panel';
|
||
const VALID_STYLES = new Set(['realtime','split-view','quad-view','classic-needles','peak-history','panel','goniometer-rtw','phase-wheel','spectrogram','waveform','recorder','clock','options-panel']);
|
||
if (!VALID_STYLES.has(style)) style = DEFAULT_STYLE;
|
||
// First boot: ensure the Real Time Analyzer is used and persisted.
|
||
if (!storedStyleRaw) {
|
||
style = DEFAULT_STYLE;
|
||
try { setPersistentItem(STYLE_STORAGE_KEY, style); } catch (_) {}
|
||
try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, style); } catch (_) {}
|
||
}
|
||
|
||
let lastNonOptionStyle = isOptionsStyle(style) ? DEFAULT_STYLE : style;
|
||
if (isOptionsStyle(style)) {
|
||
const savedLast = getPersistentItem(LAST_NON_OPTION_STYLE_KEY);
|
||
if (savedLast && VALID_STYLES.has(savedLast) && !isOptionsStyle(savedLast)) {
|
||
lastNonOptionStyle = savedLast;
|
||
}
|
||
const cfgLast = (typeof CONFIG?.UI_LAST_NON_OPTION_STYLE === 'string' && CONFIG.UI_LAST_NON_OPTION_STYLE) ? CONFIG.UI_LAST_NON_OPTION_STYLE : null;
|
||
if (cfgLast && VALID_STYLES.has(cfgLast) && !isOptionsStyle(cfgLast)) {
|
||
lastNonOptionStyle = cfgLast;
|
||
}
|
||
}
|
||
styleSel.value = style;
|
||
|
||
const popupStates = {
|
||
split: { open: false, viewId: null },
|
||
quad: { open: false, viewId: null },
|
||
};
|
||
|
||
function getSlotsForStyle(viewId = style){
|
||
const resolved = resolveStyleId(viewId);
|
||
return (slotsState[resolved] || VIEW_SLOT_DEFAULTS[resolved] || []).slice();
|
||
}
|
||
|
||
function getRequiredPlotSlots(viewId) {
|
||
const resolved = resolveStyleId(viewId);
|
||
if (resolved === 'peak-history' || resolved === 'classic-needles' || resolved === 'panel') {
|
||
const slots = getSlotsForStyle(resolved).filter((slotId) => slotId && slotId !== 'none');
|
||
if (slots.length) return slots;
|
||
}
|
||
if (resolved === 'peak-history') return ['ppm-din'];
|
||
if (resolved === 'classic-needles') return ['vu'];
|
||
if (resolved === 'panel') {
|
||
return (VIEW_SLOT_DEFAULTS.panel || []).filter((slotId) => slotId && slotId !== 'none');
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function getActiveMeterIds(viewId = style) {
|
||
const resolvedView = resolveStyleId(viewId);
|
||
const pushPlotRequiredMeters = (plotId, out) => {
|
||
if (!plotId || !out) return;
|
||
out.push(...getRequiredPlotSlots(plotId));
|
||
};
|
||
const pushViewSlotMeters = (viewId, out) => {
|
||
if (!viewId || !out) return;
|
||
const slots = getSlotsForStyle(viewId);
|
||
for (const s of (slots || [])) {
|
||
if (s && s !== 'none') out.push(s);
|
||
}
|
||
};
|
||
if (resolvedView === 'split-view') {
|
||
const ids = [];
|
||
const count = Math.max(0, Math.min(3, Number(CONFIG.SPLIT_VIEW_METER_COUNT) || 0));
|
||
const pushIf = (v) => { if (v && v !== 'none') ids.push(v); };
|
||
if (count >= 1) pushIf(CONFIG.SPLIT_VIEW_METER_1);
|
||
if (count >= 2) pushIf(CONFIG.SPLIT_VIEW_METER_2);
|
||
if (count >= 3) pushIf(CONFIG.SPLIT_VIEW_METER_3);
|
||
pushPlotRequiredMeters(CONFIG.SPLIT_VIEW_LEFT, ids);
|
||
pushPlotRequiredMeters(CONFIG.SPLIT_VIEW_RIGHT, ids);
|
||
if (popupStates.split.open && popupStates.split.viewId) {
|
||
pushViewSlotMeters(popupStates.split.viewId, ids);
|
||
pushPlotRequiredMeters(popupStates.split.viewId, ids);
|
||
}
|
||
return Array.from(new Set(ids));
|
||
}
|
||
if (resolvedView === 'quad-view') {
|
||
const ids = [];
|
||
const count = Math.max(0, Math.min(3, Number(CONFIG.QUAD_VIEW_METER_COUNT) || 0));
|
||
const pushIf = (v) => { if (v && v !== 'none') ids.push(v); };
|
||
if (count >= 1) pushIf(CONFIG.QUAD_VIEW_METER_1);
|
||
if (count >= 2) pushIf(CONFIG.QUAD_VIEW_METER_2);
|
||
if (count >= 3) pushIf(CONFIG.QUAD_VIEW_METER_3);
|
||
pushPlotRequiredMeters(CONFIG.QUAD_VIEW_TL, ids);
|
||
pushPlotRequiredMeters(CONFIG.QUAD_VIEW_TR, ids);
|
||
pushPlotRequiredMeters(CONFIG.QUAD_VIEW_BL, ids);
|
||
pushPlotRequiredMeters(CONFIG.QUAD_VIEW_BR, ids);
|
||
if (popupStates.quad.open && popupStates.quad.viewId) {
|
||
pushViewSlotMeters(popupStates.quad.viewId, ids);
|
||
pushPlotRequiredMeters(popupStates.quad.viewId, ids);
|
||
}
|
||
return Array.from(new Set(ids));
|
||
}
|
||
const resolvedSlots = getSlotsForStyle(resolvedView);
|
||
return Array.from(new Set(resolvedSlots.filter((v) => v && v !== 'none')));
|
||
}
|
||
|
||
function getActivePlotIds(viewId = style) {
|
||
const resolvedView = resolveStyleId(viewId);
|
||
const ids = [];
|
||
const pushIf = (plotId) => {
|
||
const resolved = resolveStyleId(plotId);
|
||
if (resolved && resolved !== 'none') ids.push(resolved);
|
||
};
|
||
if (resolvedView === 'split-view') {
|
||
pushIf(CONFIG.SPLIT_VIEW_LEFT);
|
||
pushIf(CONFIG.SPLIT_VIEW_RIGHT);
|
||
if (popupStates.split.open && popupStates.split.viewId) pushIf(popupStates.split.viewId);
|
||
return Array.from(new Set(ids));
|
||
}
|
||
if (resolvedView === 'quad-view') {
|
||
pushIf(CONFIG.QUAD_VIEW_TL);
|
||
pushIf(CONFIG.QUAD_VIEW_TR);
|
||
pushIf(CONFIG.QUAD_VIEW_BL);
|
||
pushIf(CONFIG.QUAD_VIEW_BR);
|
||
if (popupStates.quad.open && popupStates.quad.viewId) pushIf(popupStates.quad.viewId);
|
||
return Array.from(new Set(ids));
|
||
}
|
||
pushIf(resolvedView);
|
||
return Array.from(new Set(ids));
|
||
}
|
||
|
||
function buildProcessingProfile(viewId = style) {
|
||
const renderView = resolveStyleId(viewId);
|
||
const profile = {
|
||
needXy: false,
|
||
needRta: false,
|
||
needVu: false,
|
||
needPpmDin: false,
|
||
needPpmEbu: false,
|
||
needTp: false,
|
||
needRms: false,
|
||
needWaveform: false,
|
||
};
|
||
|
||
const plotIds = getActivePlotIds(renderView);
|
||
for (const plotId of plotIds) {
|
||
if (plotId === 'realtime') {
|
||
profile.needRta = true;
|
||
} else if (plotId === 'goniometer-rtw') {
|
||
profile.needXy = true;
|
||
profile.needRms = true;
|
||
} else if (plotId === 'phase-wheel') {
|
||
profile.needXy = true;
|
||
if ((CONFIG.PHASE_AMPLITUDE_MODE || 'bandpass') === 'ppm-din') {
|
||
profile.needPpmDin = true;
|
||
}
|
||
} else if (plotId === 'waveform') {
|
||
profile.needWaveform = true;
|
||
} else if (plotId === 'clock') {
|
||
const clockStyle = CONFIG.CLOCK_STYLE || 'analog';
|
||
if (clockStyle === 'analog-ppm' || clockStyle === 'digital-ppm') {
|
||
profile.needPpmDin = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
const meterIds = getActiveMeterIds(renderView);
|
||
for (const meterId of meterIds) {
|
||
if (meterId === 'vu') profile.needVu = true;
|
||
else if (meterId === 'ppm-din') profile.needPpmDin = true;
|
||
else if (meterId === 'ppm-ebu') profile.needPpmEbu = true;
|
||
else if (meterId === 'tp') profile.needTp = true;
|
||
else if (meterId === 'hifi-peak') profile.needTp = true;
|
||
else if (meterId === 'rms') profile.needRms = true;
|
||
else if (meterId === 'lufs') profile.needTp = true;
|
||
}
|
||
|
||
if (CONFIG.SCREENSAVER_ENABLED !== false) {
|
||
profile.needRms = true;
|
||
}
|
||
|
||
if (recorderState.autoEnabled) {
|
||
profile.needRms = true;
|
||
}
|
||
|
||
return profile;
|
||
}
|
||
function getVisibleSlotCount(viewId = style){
|
||
const resolved = resolveStyleId(viewId);
|
||
return (resolved in VIEW_SLOT_COUNTS) ? VIEW_SLOT_COUNTS[resolved] : 1;
|
||
}
|
||
function setSlotForView(viewId, idx, value){
|
||
const resolved = resolveStyleId(viewId);
|
||
if (idx >= getVisibleSlotCount(resolved)) return;
|
||
const valid = sanitizeSlotId(value);
|
||
if (!valid) return;
|
||
if (VIEWS_DISALLOW_EMPTY_SLOT.has(resolved) && valid === 'none') return;
|
||
const current = Array.isArray(slotsState[resolved]) ? slotsState[resolved].slice() : [];
|
||
current[idx] = valid;
|
||
slotsState[resolved] = normalizeSlots(resolved, current);
|
||
saveSlotState(slotsState);
|
||
|
||
const customized = loadSlotCustomizedState();
|
||
customized[resolved] = true;
|
||
saveSlotCustomizedState(customized);
|
||
|
||
const configBackedKey = CONFIG_BACKED_GLOBAL_SLOTS[resolved];
|
||
if (configBackedKey) {
|
||
CONFIG[configBackedKey] = slotsState[resolved]?.[0] || VIEW_SLOT_DEFAULTS[resolved]?.[0] || SLOT_FALLBACK_ID;
|
||
try { saveConfig?.(); } catch (_) {}
|
||
try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
function resetAllSlotsToDefaults() {
|
||
const next = {};
|
||
for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) {
|
||
next[key] = normalizeSlots(key, VIEW_SLOT_DEFAULTS[key]);
|
||
}
|
||
slotsState = next;
|
||
syncConfigBackedSlots();
|
||
saveSlotState(slotsState);
|
||
saveSlotCustomizedState({});
|
||
refreshSlotEditors();
|
||
}
|
||
function initSlotSelect(sel, idx){
|
||
if (!sel) return;
|
||
sel.dataset.slotIndex = String(idx);
|
||
sel.innerHTML = '';
|
||
for (const [val,label] of SLOT_OPTIONS){
|
||
const o = document.createElement('option');
|
||
o.value = val; o.textContent = label;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.onchange = () => {
|
||
if (isOptionsStyle(style)) return;
|
||
setSlotForView(style, idx, sel.value);
|
||
syncSlotSelectValues(style);
|
||
};
|
||
}
|
||
slotSelects.forEach((sel, idx) => initSlotSelect(sel, idx));
|
||
|
||
if (phaseTrailChk) {
|
||
phaseTrailChk.addEventListener('change', () => {
|
||
CONFIG.PHASE_TRAIL_ENABLED = !!phaseTrailChk.checked;
|
||
saveConfig();
|
||
});
|
||
}
|
||
if (goniAgcChk) {
|
||
goniAgcChk.addEventListener('change', () => {
|
||
setGoniAgcEnabled(!!goniAgcChk.checked);
|
||
saveConfig();
|
||
});
|
||
}
|
||
|
||
function syncSlotSelectValues(viewId = style){
|
||
const resolved = resolveStyleId(viewId);
|
||
const slots = getSlotsForStyle(resolved);
|
||
const visibleCount = getVisibleSlotCount(resolved);
|
||
const disallowEmpty = VIEWS_DISALLOW_EMPTY_SLOT.has(resolved);
|
||
const tip = disallowEmpty
|
||
? "Hinweis: Diese Anzeige ist vom Slot abhängig; '(leer)' ist hier nicht verfügbar."
|
||
: '';
|
||
slotSelects.forEach((sel, idx) => {
|
||
if (!sel) return;
|
||
const show = idx < visibleCount;
|
||
sel.style.display = show ? 'inline-block' : 'none';
|
||
if (show) {
|
||
sel.title = tip;
|
||
for (const opt of sel.options) {
|
||
if (opt && opt.value === 'none') {
|
||
opt.disabled = disallowEmpty;
|
||
opt.title = tip;
|
||
}
|
||
}
|
||
sel.value = slots[idx] || SLOT_FALLBACK_ID;
|
||
} else {
|
||
sel.title = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
function ensureNoEmptySlotsForView(viewId = style){
|
||
const resolved = resolveStyleId(viewId);
|
||
if (!VIEWS_DISALLOW_EMPTY_SLOT.has(resolved)) return;
|
||
const current = Array.isArray(slotsState?.[resolved]) ? slotsState[resolved].slice() : [];
|
||
if (!current.includes('none')) return;
|
||
slotsState[resolved] = normalizeSlots(resolved, current);
|
||
saveSlotState(slotsState);
|
||
}
|
||
|
||
function updateSlotLabel(viewId = style){
|
||
if (!slotLabel) return;
|
||
const count = getVisibleSlotCount(viewId);
|
||
slotLabel.textContent = count === 1 ? 'Slot' : 'Slots';
|
||
}
|
||
function refreshSlotEditors(){
|
||
if (!slotWrap) return;
|
||
const isOptionsView = isOptionsStyle(style);
|
||
const isClockView = style === 'clock';
|
||
const isSplitView = style === 'split-view';
|
||
const isQuadView = style === 'quad-view';
|
||
const visibleCount = getVisibleSlotCount(style);
|
||
|
||
if (isOptionsView || isClockView) {
|
||
slotWrap.style.display = 'none';
|
||
if (splitViewWrap) splitViewWrap.style.display = 'none';
|
||
if (quadViewWrap) quadViewWrap.style.display = 'none';
|
||
setSplitMeterPopupOpen(false);
|
||
setQuadPlotPopupOpen(false);
|
||
if (gainWrap) gainWrap.style.display = 'none';
|
||
if (spectroLegendWrap) spectroLegendWrap.style.display = 'none';
|
||
if (waveWindowWrap) waveWindowWrap.style.display = 'none';
|
||
if (waveModeWrap) waveModeWrap.style.display = 'none';
|
||
if (rtaBpoWrap) rtaBpoWrap.style.display = 'none';
|
||
if (goniAgcToggle) goniAgcToggle.style.display = 'none';
|
||
if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none';
|
||
if (waveWindowSel) {
|
||
waveWindowSel.value = String(getNearestWaveWindow(CONFIG.WAVEFORM_WINDOW_SEC ?? 1));
|
||
}
|
||
if (waveModeSel) {
|
||
waveModeSel.value = normalizeWaveMode(CONFIG.WAVEFORM_MODE);
|
||
}
|
||
if (rtaBpoSel) {
|
||
rtaBpoSel.value = normalizeRtaBpo(CONFIG.RTA_BPO_MODE);
|
||
}
|
||
if (recorderHud) recorderHud.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
if (isSplitView) {
|
||
slotWrap.style.display = 'none';
|
||
if (quadViewWrap) quadViewWrap.style.display = 'none';
|
||
setQuadPlotPopupOpen(false);
|
||
if (gainWrap) gainWrap.style.display = 'none';
|
||
if (spectroLegendWrap) spectroLegendWrap.style.display = 'none';
|
||
if (waveWindowWrap) waveWindowWrap.style.display = 'none';
|
||
if (waveModeWrap) waveModeWrap.style.display = 'none';
|
||
if (rtaBpoWrap) rtaBpoWrap.style.display = 'none';
|
||
if (goniAgcToggle) goniAgcToggle.style.display = 'none';
|
||
if (phaseTrailToggle) phaseTrailToggle.style.display = 'none';
|
||
if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none';
|
||
if (recorderHud) recorderHud.style.display = 'none';
|
||
syncSplitViewControls();
|
||
return;
|
||
}
|
||
if (isQuadView) {
|
||
slotWrap.style.display = 'none';
|
||
if (splitViewWrap) splitViewWrap.style.display = 'none';
|
||
if (gainWrap) gainWrap.style.display = 'none';
|
||
if (spectroLegendWrap) spectroLegendWrap.style.display = 'none';
|
||
if (waveWindowWrap) waveWindowWrap.style.display = 'none';
|
||
if (waveModeWrap) waveModeWrap.style.display = 'none';
|
||
if (rtaBpoWrap) rtaBpoWrap.style.display = 'none';
|
||
if (goniAgcToggle) goniAgcToggle.style.display = 'none';
|
||
if (phaseTrailToggle) phaseTrailToggle.style.display = 'none';
|
||
if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none';
|
||
if (recorderHud) recorderHud.style.display = 'none';
|
||
syncQuadViewControls();
|
||
return;
|
||
}
|
||
|
||
if (splitViewWrap) splitViewWrap.style.display = 'none';
|
||
if (quadViewWrap) quadViewWrap.style.display = 'none';
|
||
setSplitMeterPopupOpen(false);
|
||
setQuadPlotPopupOpen(false);
|
||
const hideSlots = visibleCount === 0;
|
||
if (hideSlots) {
|
||
slotWrap.style.display = 'none';
|
||
if (gainWrap) gainWrap.style.display = 'none';
|
||
if (spectroLegendWrap) spectroLegendWrap.style.display = 'none';
|
||
if (waveWindowWrap) waveWindowWrap.style.display = 'none';
|
||
if (waveModeWrap) waveModeWrap.style.display = 'none';
|
||
if (rtaBpoWrap) rtaBpoWrap.style.display = 'none';
|
||
if (goniAgcToggle) goniAgcToggle.style.display = 'none';
|
||
if (phaseTrailToggle) phaseTrailToggle.style.display = 'none';
|
||
if (peakHistScrollWrap) peakHistScrollWrap.style.display = 'none';
|
||
if (recorderHud) recorderHud.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
ensureNoEmptySlotsForView(style);
|
||
slotWrap.style.display = 'flex';
|
||
if (recorderHud) recorderHud.style.display = (style === 'recorder') ? 'flex' : 'none';
|
||
updateSlotLabel(style);
|
||
syncSlotSelectValues(style);
|
||
syncRealtimeGainControl();
|
||
syncWaveWindowControl();
|
||
syncWaveModeControl();
|
||
syncRtaBpoControl();
|
||
syncSpectrogramLegendVisibility();
|
||
syncPhaseTrailToggle();
|
||
syncGoniAgcToggle();
|
||
syncPeakHistScrollControl();
|
||
}
|
||
|
||
const SPLIT_PLOT_OPTIONS = [
|
||
['none', '(leer)'],
|
||
['phase-wheel', 'Phase Wheel'],
|
||
['realtime', 'Real Time Analyzer'],
|
||
['goniometer-rtw', 'XY-Display'],
|
||
['peak-history', 'Peak History'],
|
||
['classic-needles', 'Classic Needles'],
|
||
['panel', 'Meters'],
|
||
['clock', 'Studio Clock'],
|
||
['waveform', 'Waveform'],
|
||
['spectrogram', 'Spectrogram'],
|
||
];
|
||
const SPLIT_PLOT_VALUE_SET = new Set(SPLIT_PLOT_OPTIONS.map(([id]) => id));
|
||
function sanitizeSplitPlotId(id) {
|
||
return SPLIT_PLOT_VALUE_SET.has(id) ? id : null;
|
||
}
|
||
function initSplitPlotSelect(sel, onChange) {
|
||
if (!sel) return;
|
||
sel.innerHTML = '';
|
||
for (const [val, label] of SPLIT_PLOT_OPTIONS) {
|
||
const o = document.createElement('option');
|
||
o.value = val;
|
||
o.textContent = label;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.addEventListener('change', () => onChange(sel.value));
|
||
}
|
||
initSplitPlotSelect(splitLeftSel, (val) => {
|
||
const clean = sanitizeSplitPlotId(val) ?? 'none';
|
||
CONFIG.SPLIT_VIEW_LEFT = clean;
|
||
saveConfig();
|
||
});
|
||
initSplitPlotSelect(splitRightSel, (val) => {
|
||
const clean = sanitizeSplitPlotId(val) ?? 'none';
|
||
CONFIG.SPLIT_VIEW_RIGHT = clean;
|
||
saveConfig();
|
||
});
|
||
|
||
function clampMeterCount3(v) {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return 0;
|
||
return Math.max(0, Math.min(3, n | 0));
|
||
}
|
||
|
||
function initSplitMeterTypeSelect(sel, onChange) {
|
||
if (!sel) return;
|
||
sel.innerHTML = '';
|
||
for (const [val, label] of SLOT_OPTIONS) {
|
||
const o = document.createElement('option');
|
||
o.value = val;
|
||
o.textContent = label;
|
||
sel.appendChild(o);
|
||
}
|
||
sel.addEventListener('change', () => onChange(sel.value));
|
||
}
|
||
|
||
let meterPopupPrefix = 'SPLIT_VIEW';
|
||
function getMeterKey(prefix, idx) {
|
||
const i = Math.max(1, Math.min(3, Number(idx) || 1));
|
||
return `${prefix}_METER_${i}`;
|
||
}
|
||
function getMeterPosKey(prefix, idx) {
|
||
const i = Math.max(1, Math.min(3, Number(idx) || 1));
|
||
return `${prefix}_METER_${i}_POS`;
|
||
}
|
||
function getMeterCountKey(prefix) {
|
||
return `${prefix}_METER_COUNT`;
|
||
}
|
||
function readMeterFromConfig(prefix, idx) {
|
||
const key = getMeterKey(prefix, idx);
|
||
return sanitizeSlotId(CONFIG?.[key]) ?? 'vu';
|
||
}
|
||
|
||
function writeMeterToConfig(prefix, idx, val) {
|
||
const key = getMeterKey(prefix, idx);
|
||
CONFIG[key] = sanitizeSlotId(val) ?? 'vu';
|
||
saveConfig();
|
||
}
|
||
|
||
function setSplitMeterPopupOpen(open) {
|
||
if (!splitMeterPopup) return;
|
||
splitMeterPopup.style.display = open ? 'flex' : 'none';
|
||
}
|
||
|
||
function syncSplitMeterPopup() {
|
||
const meterCount = clampMeterCount3(CONFIG?.[getMeterCountKey(meterPopupPrefix)]);
|
||
if (splitMeterRow1) splitMeterRow1.style.display = meterCount >= 1 ? 'flex' : 'none';
|
||
if (splitMeterRow2) splitMeterRow2.style.display = meterCount >= 2 ? 'flex' : 'none';
|
||
if (splitMeterRow3) splitMeterRow3.style.display = meterCount >= 3 ? 'flex' : 'none';
|
||
if (splitMeterSel1) splitMeterSel1.value = readMeterFromConfig(meterPopupPrefix, 1);
|
||
if (splitMeterSel2) splitMeterSel2.value = readMeterFromConfig(meterPopupPrefix, 2);
|
||
if (splitMeterSel3) splitMeterSel3.value = readMeterFromConfig(meterPopupPrefix, 3);
|
||
}
|
||
|
||
initSplitMeterTypeSelect(splitMeterSel1, (val) => {
|
||
if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return;
|
||
writeMeterToConfig(meterPopupPrefix, 1, val);
|
||
syncSplitMeterPopup();
|
||
});
|
||
initSplitMeterTypeSelect(splitMeterSel2, (val) => {
|
||
if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return;
|
||
writeMeterToConfig(meterPopupPrefix, 2, val);
|
||
syncSplitMeterPopup();
|
||
});
|
||
initSplitMeterTypeSelect(splitMeterSel3, (val) => {
|
||
if (isOptionsStyle(style) || (style !== 'split-view' && style !== 'quad-view')) return;
|
||
writeMeterToConfig(meterPopupPrefix, 3, val);
|
||
syncSplitMeterPopup();
|
||
});
|
||
|
||
function setQuadPlotPopupOpen(open) {
|
||
if (!quadPlotPopup) return;
|
||
quadPlotPopup.style.display = open ? 'flex' : 'none';
|
||
}
|
||
|
||
function syncQuadPlotPopup() {
|
||
if (quadPlotSelTL) quadPlotSelTL.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_TL) ?? 'phase-wheel';
|
||
if (quadPlotSelTR) quadPlotSelTR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_TR) ?? 'realtime';
|
||
if (quadPlotSelBL) quadPlotSelBL.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BL) ?? 'goniometer-rtw';
|
||
if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none';
|
||
}
|
||
|
||
initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; saveConfig(); });
|
||
initSplitPlotSelect(quadPlotSelTR, (val) => { CONFIG.QUAD_VIEW_TR = sanitizeSplitPlotId(val) ?? 'realtime'; saveConfig(); });
|
||
initSplitPlotSelect(quadPlotSelBL, (val) => { CONFIG.QUAD_VIEW_BL = sanitizeSplitPlotId(val) ?? 'goniometer-rtw'; saveConfig(); });
|
||
initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; saveConfig(); });
|
||
|
||
if (splitMeterBtn) {
|
||
splitMeterBtn.addEventListener('click', () => {
|
||
if (style !== 'split-view' || isOptionsStyle(style)) return;
|
||
if (splitMeterPopupTitle) splitMeterPopupTitle.textContent = 'Split-View Slots';
|
||
meterPopupPrefix = 'SPLIT_VIEW';
|
||
syncSplitMeterPopup();
|
||
setSplitMeterPopupOpen(true);
|
||
});
|
||
}
|
||
if (quadMeterBtn) {
|
||
const openQuadSlots = (ev) => {
|
||
if (isOptionsStyle(style) || style !== 'quad-view') return;
|
||
try { ev?.preventDefault?.(); } catch (_) {}
|
||
if (splitMeterPopupTitle) splitMeterPopupTitle.textContent = 'Quad-View Slots';
|
||
meterPopupPrefix = 'QUAD_VIEW';
|
||
syncSplitMeterPopup();
|
||
setSplitMeterPopupOpen(true);
|
||
};
|
||
quadMeterBtn.addEventListener('click', openQuadSlots);
|
||
}
|
||
if (quadPlotBtn) {
|
||
const openQuadPlots = (ev) => {
|
||
if (isOptionsStyle(style) || style !== 'quad-view') return;
|
||
try { ev?.preventDefault?.(); } catch (_) {}
|
||
syncQuadPlotPopup();
|
||
setQuadPlotPopupOpen(true);
|
||
};
|
||
quadPlotBtn.addEventListener('click', openQuadPlots);
|
||
}
|
||
if (splitMeterPopupClose) {
|
||
splitMeterPopupClose.addEventListener('click', () => setSplitMeterPopupOpen(false));
|
||
}
|
||
if (splitMeterPopup) {
|
||
splitMeterPopup.addEventListener('pointerdown', (e) => {
|
||
if (e?.target === splitMeterPopup) setSplitMeterPopupOpen(false);
|
||
});
|
||
const box = splitMeterPopup.querySelector?.('.split-popup__box');
|
||
box?.addEventListener?.('pointerdown', (e) => e.stopPropagation());
|
||
}
|
||
if (quadPlotPopupClose) {
|
||
quadPlotPopupClose.addEventListener('click', () => setQuadPlotPopupOpen(false));
|
||
}
|
||
if (quadPlotPopup) {
|
||
quadPlotPopup.addEventListener('pointerdown', (e) => {
|
||
if (e?.target === quadPlotPopup) setQuadPlotPopupOpen(false);
|
||
});
|
||
const box = quadPlotPopup.querySelector?.('.split-popup__box');
|
||
box?.addEventListener?.('pointerdown', (e) => e.stopPropagation());
|
||
}
|
||
addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape') return;
|
||
if (splitMeterPopup && splitMeterPopup.style.display !== 'none') setSplitMeterPopupOpen(false);
|
||
if (quadPlotPopup && quadPlotPopup.style.display !== 'none') setQuadPlotPopupOpen(false);
|
||
if (env?.splitPopup?.open) {
|
||
env.splitPopup.open = false;
|
||
env.splitPopup.viewId = null;
|
||
}
|
||
if (env?.quadPopup?.open) {
|
||
env.quadPopup.open = false;
|
||
env.quadPopup.viewId = null;
|
||
}
|
||
});
|
||
|
||
function syncSplitViewControls() {
|
||
if (!splitViewWrap || !splitLeftSel || !splitRightSel) return;
|
||
const show = style === 'split-view' && !isOptionsStyle(style);
|
||
splitViewWrap.style.display = show ? 'flex' : 'none';
|
||
if (!show) return;
|
||
splitLeftSel.value = sanitizeSplitPlotId(CONFIG.SPLIT_VIEW_LEFT) ?? 'phase-wheel';
|
||
splitRightSel.value = sanitizeSplitPlotId(CONFIG.SPLIT_VIEW_RIGHT) ?? 'realtime';
|
||
|
||
const meterCount = clampMeterCount3(CONFIG.SPLIT_VIEW_METER_COUNT);
|
||
const showMetersBtn = meterCount >= 1;
|
||
if (splitMeterBtnWrap) splitMeterBtnWrap.style.display = showMetersBtn ? 'inline-flex' : 'none';
|
||
if (showMetersBtn) syncSplitMeterPopup();
|
||
else setSplitMeterPopupOpen(false);
|
||
}
|
||
syncSplitViewControls();
|
||
|
||
function syncQuadViewControls() {
|
||
if (!quadViewWrap) return;
|
||
const show = style === 'quad-view' && !isOptionsStyle(style);
|
||
quadViewWrap.style.display = show ? 'flex' : 'none';
|
||
if (!show) {
|
||
setQuadPlotPopupOpen(false);
|
||
return;
|
||
}
|
||
const meterCount = clampMeterCount3(CONFIG.QUAD_VIEW_METER_COUNT);
|
||
const showMetersBtn = meterCount >= 1;
|
||
if (quadMeterBtnWrap) quadMeterBtnWrap.style.display = showMetersBtn ? 'inline-flex' : 'none';
|
||
if (!showMetersBtn) setSplitMeterPopupOpen(false);
|
||
}
|
||
syncQuadViewControls();
|
||
|
||
refreshSlotEditors();
|
||
|
||
function syncRealtimeGainControl(){
|
||
const isRealtime = (style === 'realtime');
|
||
const iirEngine = (CONFIG.RTA_ENGINE || 'fft') === 'iir';
|
||
if (gainWrap && gainSel) {
|
||
const showGain = isRealtime && iirEngine;
|
||
gainWrap.style.display = showGain ? 'flex' : 'none';
|
||
if (showGain) {
|
||
let val = Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB);
|
||
if (!REALTIME_GAIN_CHOICES.includes(val)) val = 0;
|
||
gainSel.value = String(val);
|
||
}
|
||
}
|
||
if (resetPeakBtn) {
|
||
const showBtn = isRealtime && (CONFIG.RTA_PEAK_HOLD_MODE === 'manual');
|
||
resetPeakBtn.style.display = showBtn ? 'inline-flex' : 'none';
|
||
}
|
||
}
|
||
|
||
function syncSpectrogramLegendVisibility(){
|
||
if (!spectroLegendWrap) return;
|
||
const show = (style === 'spectrogram') && !isOptionsStyle(style);
|
||
spectroLegendWrap.style.display = show ? 'flex' : 'none';
|
||
}
|
||
|
||
function syncWaveWindowControl(){
|
||
if (!waveWindowWrap || !waveWindowSel) return;
|
||
const show = (style === 'waveform') && !isOptionsStyle(style);
|
||
waveWindowWrap.style.display = show ? 'flex' : 'none';
|
||
const val = getNearestWaveWindow(CONFIG.WAVEFORM_WINDOW_SEC ?? 1);
|
||
waveWindowSel.value = String(val);
|
||
}
|
||
|
||
function syncWaveModeControl(){
|
||
if (!waveModeWrap || !waveModeSel) return;
|
||
const show = (style === 'waveform') && !isOptionsStyle(style);
|
||
waveModeWrap.style.display = show ? 'flex' : 'none';
|
||
waveModeSel.value = normalizeWaveMode(CONFIG.WAVEFORM_MODE);
|
||
}
|
||
|
||
function syncPeakHistScrollControl(){
|
||
if (!peakHistScrollWrap || !peakHistScrollSel) return;
|
||
const show = (style === 'peak-history') && !isOptionsStyle(style);
|
||
peakHistScrollWrap.style.display = show ? 'flex' : 'none';
|
||
if (show) {
|
||
const val = normalizePeakHistScroll(CONFIG.PEAK_HISTORY_SCROLL_MODE);
|
||
peakHistScrollSel.value = String(val);
|
||
}
|
||
}
|
||
|
||
function hasActivePhaseWheelPlot() {
|
||
if (isOptionsStyle(style)) return false;
|
||
return getActivePlotIds(getRenderableStyle()).includes('phase-wheel');
|
||
}
|
||
|
||
function syncPhaseTrailToggle(){
|
||
if (!phaseTrailToggle || !phaseTrailChk) return;
|
||
const show = hasActivePhaseWheelPlot();
|
||
phaseTrailToggle.style.display = show ? 'inline-flex' : 'none';
|
||
if (show) {
|
||
phaseTrailChk.checked = !!CONFIG.PHASE_TRAIL_ENABLED;
|
||
}
|
||
}
|
||
|
||
function syncRtaBpoControl(){
|
||
if (!rtaBpoWrap || !rtaBpoSel) return;
|
||
const show = (style === 'realtime') && !isOptionsStyle(style);
|
||
rtaBpoWrap.style.display = show ? 'flex' : 'none';
|
||
rtaBpoSel.value = normalizeRtaBpo(CONFIG.RTA_BPO_MODE);
|
||
}
|
||
|
||
function syncGoniAgcToggle(){
|
||
if (!goniAgcToggle || !goniAgcChk) return;
|
||
const show = style === 'goniometer-rtw' && !isOptionsStyle(style);
|
||
goniAgcToggle.style.display = show ? 'inline-flex' : 'none';
|
||
if (show) {
|
||
goniAgcChk.checked = !!CONFIG.GONIO_AGC_ENABLED;
|
||
}
|
||
}
|
||
|
||
function setGoniAgcEnabled(enabled){
|
||
const targetState = !!enabled;
|
||
const wasEnabled = !!CONFIG.GONIO_AGC_ENABLED;
|
||
const gainSliderEl = document.getElementById('opt_goniGain');
|
||
const gainLabel = document.getElementById('val_goniGain');
|
||
const optionsToggle = document.getElementById('opt_goniAgc');
|
||
const hudToggle = document.getElementById('goniAgcChk');
|
||
const sliderStored = gainSliderEl && gainSliderEl.dataset && typeof gainSliderEl.dataset.lastManualGain === 'string'
|
||
? sanitizeGoniGain(gainSliderEl.dataset.lastManualGain)
|
||
: null;
|
||
const sliderVal = (!wasEnabled && gainSliderEl)
|
||
? sanitizeGoniGain(gainSliderEl.value)
|
||
: null;
|
||
let manual = sanitizeGoniGain(CONFIG.GONIO_MANUAL_GAIN_DB ?? sliderStored ?? CONFIG.GONIO_DISPLAY_GAIN_DB);
|
||
if (targetState) {
|
||
if (sliderVal !== null) manual = sliderVal;
|
||
else if (sliderStored !== null) manual = sliderStored;
|
||
CONFIG.GONIO_MANUAL_GAIN_DB = manual;
|
||
CONFIG.GONIO_DISPLAY_GAIN_DB = 0;
|
||
} else {
|
||
const restore = sanitizeGoniGain(CONFIG.GONIO_MANUAL_GAIN_DB ?? sliderStored ?? sliderVal ?? CONFIG.GONIO_DISPLAY_GAIN_DB ?? manual);
|
||
CONFIG.GONIO_MANUAL_GAIN_DB = restore;
|
||
CONFIG.GONIO_DISPLAY_GAIN_DB = restore;
|
||
manual = restore;
|
||
}
|
||
CONFIG.GONIO_AGC_ENABLED = targetState;
|
||
if (gainSliderEl) {
|
||
gainSliderEl.disabled = targetState;
|
||
gainSliderEl.value = targetState ? '0' : String(manual);
|
||
gainSliderEl.dataset.lastManualGain = targetState ? String(manual) : String(manual);
|
||
}
|
||
if (gainLabel) {
|
||
const labelVal = targetState ? 0 : manual;
|
||
gainLabel.textContent = `${labelVal} dB`;
|
||
}
|
||
if (optionsToggle && optionsToggle.checked !== targetState) {
|
||
optionsToggle.checked = targetState;
|
||
}
|
||
if (hudToggle && hudToggle.checked !== targetState) {
|
||
hudToggle.checked = targetState;
|
||
}
|
||
}
|
||
|
||
function applyStyleSelection(nextStyle, opts = {}) {
|
||
const persistStorage = opts.persistStorage !== false;
|
||
const persistConfig = opts.persistConfig !== false;
|
||
const targetStyle = VALID_STYLES.has(nextStyle) ? nextStyle : DEFAULT_STYLE;
|
||
if (targetStyle === 'clock' && style !== 'clock') {
|
||
lastViewBeforeClock = getRenderableStyle();
|
||
}
|
||
if (style === 'recorder' && targetStyle !== 'recorder' && recorderState.autoEnabled) {
|
||
setRecorderAutoEnabled(false);
|
||
}
|
||
style = targetStyle;
|
||
if (styleSel && styleSel.value !== targetStyle) {
|
||
styleSel.value = targetStyle;
|
||
}
|
||
if (persistStorage) {
|
||
setPersistentItem(STYLE_STORAGE_KEY, style);
|
||
}
|
||
adjustStyleDropdownWidth();
|
||
refreshSlotEditors();
|
||
toggleOptionsPanels();
|
||
if (!isOptionsStyle(style)) {
|
||
lastNonOptionStyle = style;
|
||
if (persistStorage) {
|
||
try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, style); } catch (_) {}
|
||
}
|
||
setView(style);
|
||
} else {
|
||
const renderStyle = getRenderableStyle();
|
||
if (!currentView || currentViewId !== renderStyle) {
|
||
setView(renderStyle);
|
||
}
|
||
}
|
||
if (persistConfig) {
|
||
persistStyleToConfig(style);
|
||
}
|
||
}
|
||
|
||
styleSel.onchange = () => {
|
||
applyStyleSelection(styleSel.value);
|
||
};
|
||
|
||
import * as viewClock from './views/clock.js';
|
||
|
||
// --- Register Meters ---
|
||
registerMeter(vu);
|
||
registerMeter(ppmEbu);
|
||
registerMeter(ppmDin);
|
||
registerMeter(tp);
|
||
registerMeter(hifiPeak);
|
||
registerMeter(rms);
|
||
registerMeter(lufs);
|
||
registerMeter(stopwatch);
|
||
|
||
const screensaver = createScreensaver({ config: CONFIG, body: document.body });
|
||
screensaver.setEnabled(CONFIG.SCREENSAVER_ENABLED !== false);
|
||
|
||
// --- Options UI --------------------------------------------------------------
|
||
const opts = setupOptions({
|
||
audioReload: async () => { await bootAudio(true); },
|
||
resetSlotsToDefaults: () => resetAllSlotsToDefaults(),
|
||
invalidateMeters: () => {
|
||
if (typeof meterFacade.invalidateAll === 'function') {
|
||
meterFacade.invalidateAll();
|
||
}
|
||
},
|
||
notifyRtaConfig: () => {
|
||
try { env.audio.updateRtaConfig?.(); } catch (_) {}
|
||
},
|
||
notifyPhoenixGlobalConfig: () => {
|
||
try { env.audio.updatePhoenixGlobalConfig?.(); } catch (_) {}
|
||
},
|
||
notifyPpmConfig: () => {
|
||
try { env.audio.updatePpmConfig?.(); } catch (_) {}
|
||
},
|
||
syncRealtimeGain: () => {
|
||
syncRealtimeGainControl();
|
||
},
|
||
updateInputOffset: () => {
|
||
try { env.audio.updateInputOffset?.(); } catch (_) {}
|
||
},
|
||
updateMonoInput: () => {
|
||
try { env.audio.updateMonoMode?.(); } catch (_) {}
|
||
},
|
||
syncWaveformWindow: () => {
|
||
syncWaveWindowControl();
|
||
},
|
||
syncWaveformMode: () => {
|
||
syncWaveModeControl();
|
||
},
|
||
syncRtaBpo: () => {
|
||
syncRtaBpoControl();
|
||
},
|
||
syncSplitView: () => {
|
||
syncSplitViewControls();
|
||
syncQuadViewControls();
|
||
},
|
||
buildPresetSnapshot: () => {
|
||
const snapshot = JSON.parse(JSON.stringify(CONFIG));
|
||
delete snapshot.Y_TICKS;
|
||
delete snapshot.INPUT_OFFSET_GAIN_L;
|
||
delete snapshot.INPUT_OFFSET_GAIN_R;
|
||
snapshot.__PHOENIX_UI_STATE = buildPresetUiState();
|
||
return snapshot;
|
||
},
|
||
buildPresetUiState: () => {
|
||
return buildPresetUiState();
|
||
},
|
||
applyPresetUiState: (uiState) => {
|
||
applyPresetUiState(uiState);
|
||
},
|
||
switchView: (id) => {
|
||
const target = VALID_STYLES.has(id) ? id : DEFAULT_STYLE;
|
||
if (styleSel) {
|
||
styleSel.value = target;
|
||
styleSel.dispatchEvent(new Event('change'));
|
||
} else {
|
||
style = target;
|
||
setView(target);
|
||
requestRender('preset-switch-view');
|
||
}
|
||
},
|
||
requestRender: () => {
|
||
requestRender('preset-refresh');
|
||
},
|
||
updateLrDelay: () => {
|
||
try { env.audio.updateLrDelay?.(); } catch (_) {}
|
||
},
|
||
screensaver,
|
||
});
|
||
toggleOptionsPanels();
|
||
adjustStyleDropdownWidth();
|
||
maybeShowCalibrationNotice();
|
||
|
||
function buildPresetUiState() {
|
||
const slots = {};
|
||
for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) {
|
||
slots[key] = normalizeSlots(key, slotsState?.[key]);
|
||
}
|
||
return {
|
||
style: VALID_STYLES.has(style) ? style : DEFAULT_STYLE,
|
||
lastNonOptionStyle: (VALID_STYLES.has(lastNonOptionStyle) && !isOptionsStyle(lastNonOptionStyle)) ? lastNonOptionStyle : DEFAULT_STYLE,
|
||
slots,
|
||
slotCustomized: loadSlotCustomizedState(),
|
||
};
|
||
}
|
||
|
||
function applyPresetUiState(uiState = null) {
|
||
if (!uiState || typeof uiState !== 'object' || Array.isArray(uiState)) return;
|
||
|
||
if (uiState.slots && typeof uiState.slots === 'object' && !Array.isArray(uiState.slots)) {
|
||
const next = {};
|
||
for (const key of Object.keys(VIEW_SLOT_DEFAULTS)) {
|
||
next[key] = normalizeSlots(key, uiState.slots[key]);
|
||
}
|
||
slotsState = next;
|
||
saveSlotState(slotsState);
|
||
}
|
||
syncConfigBackedSlots();
|
||
|
||
const customized = (uiState.slotCustomized && typeof uiState.slotCustomized === 'object' && !Array.isArray(uiState.slotCustomized))
|
||
? uiState.slotCustomized
|
||
: {};
|
||
saveSlotCustomizedState(customized);
|
||
|
||
const savedLast = String(uiState.lastNonOptionStyle || '').trim();
|
||
if (savedLast && VALID_STYLES.has(savedLast) && !isOptionsStyle(savedLast)) {
|
||
lastNonOptionStyle = savedLast;
|
||
try { setPersistentItem(LAST_NON_OPTION_STYLE_KEY, savedLast); } catch (_) {}
|
||
}
|
||
|
||
const savedStyle = String(uiState.style || '').trim();
|
||
if (savedStyle && VALID_STYLES.has(savedStyle)) {
|
||
try { setPersistentItem(STYLE_STORAGE_KEY, savedStyle); } catch (_) {}
|
||
}
|
||
|
||
refreshSlotEditors();
|
||
}
|
||
|
||
function adjustStyleDropdownWidth(){
|
||
if (!styleSel) return;
|
||
const opt = styleSel.options[styleSel.selectedIndex];
|
||
const rawText = (opt ? opt.textContent : '') || '';
|
||
const targetText = (rawText.trim() === 'Meters') ? rawText : 'Real Time Analyzer';
|
||
const computed = window.getComputedStyle(styleSel);
|
||
const font = computed.font || '16px ui-monospace, monospace';
|
||
const paddingLeft = parseFloat(computed.paddingLeft) || 0;
|
||
const paddingRight = parseFloat(computed.paddingRight) || 0;
|
||
const arrowAllowance = 24;
|
||
if (!adjustStyleDropdownWidth.ctx) {
|
||
adjustStyleDropdownWidth.canvas = document.createElement('canvas');
|
||
adjustStyleDropdownWidth.ctx = adjustStyleDropdownWidth.canvas.getContext('2d');
|
||
}
|
||
const ctx = adjustStyleDropdownWidth.ctx;
|
||
ctx.font = font;
|
||
const measured = ctx.measureText(targetText).width;
|
||
const width = Math.ceil(measured + paddingLeft + paddingRight + arrowAllowance);
|
||
styleSel.style.width = width + 'px';
|
||
}
|
||
adjustStyleDropdownWidth.canvas = null;
|
||
adjustStyleDropdownWidth.ctx = null;
|
||
|
||
function isOptionsStyle(val) {
|
||
return val === 'options-panel';
|
||
}
|
||
|
||
function getRenderableStyle() {
|
||
return isOptionsStyle(style) ? (lastNonOptionStyle || DEFAULT_STYLE) : style;
|
||
}
|
||
|
||
let persistStyleFollowupTimer = 0;
|
||
function persistStyleToConfig(currentStyle) {
|
||
CONFIG.UI_LAST_STYLE = currentStyle;
|
||
if (!isOptionsStyle(currentStyle)) {
|
||
CONFIG.UI_LAST_NON_OPTION_STYLE = currentStyle;
|
||
}
|
||
// Sofort speichern (damit ein schneller Neustart nicht 3–5s Wartezeit braucht).
|
||
try { saveConfig(); } catch (_) {}
|
||
// Best effort: einmal kurz danach nochmal speichern.
|
||
if (persistStyleFollowupTimer) {
|
||
try { clearTimeout(persistStyleFollowupTimer); } catch (_) {}
|
||
}
|
||
persistStyleFollowupTimer = setTimeout(() => {
|
||
persistStyleFollowupTimer = 0;
|
||
if (style !== currentStyle) return;
|
||
CONFIG.UI_LAST_STYLE = currentStyle;
|
||
if (!isOptionsStyle(currentStyle)) CONFIG.UI_LAST_NON_OPTION_STYLE = currentStyle;
|
||
try { saveConfig(); } catch (_) {}
|
||
}, 800);
|
||
}
|
||
|
||
function toggleOptionsPanels() {
|
||
if (!optionsPanelNew) return;
|
||
const showNewPanel = isOptionsStyle(style);
|
||
optionsPanelNew.style.display = showNewPanel ? 'block' : 'none';
|
||
if (showNewPanel) collapseNewOptionsSections();
|
||
}
|
||
|
||
function collapseNewOptionsSections() {
|
||
if (!optionsPanelNew) return;
|
||
const detailsNodes = optionsPanelNew.querySelectorAll('details');
|
||
detailsNodes.forEach((node) => {
|
||
node.open = false;
|
||
});
|
||
}
|
||
|
||
// --- Views-Tabelle -----------------------------------------------------------
|
||
const VIEWS = {
|
||
'realtime': viewRealtime,
|
||
'split-view': viewSplit,
|
||
'quad-view': viewQuad,
|
||
'classic-needles': viewClassicNeedles,
|
||
'peak-history': viewPeakHistory,
|
||
'goniometer-rtw': viewGoni,
|
||
'phase-wheel': viewPhaseWheel,
|
||
'panel': viewPanel,
|
||
'spectrogram': viewSpectrogram,
|
||
'waveform': viewWaveform,
|
||
'recorder': viewRecorder,
|
||
'clock': viewClock,
|
||
};
|
||
let currentView = null;
|
||
let viewState = null;
|
||
let currentViewId = null;
|
||
let frameCounter = 0;
|
||
let lastViewBeforeClock = null;
|
||
|
||
function markCalibrationNoticeSeen() {
|
||
calibrationNoticeSeen = true;
|
||
try { localStorage.setItem(CALIBRATION_NOTICE_KEY, '1'); } catch (_) {}
|
||
try { sessionStorage.setItem(CALIBRATION_NOTICE_KEY, '1'); } catch (_) {}
|
||
}
|
||
|
||
function hasSeenCalibrationNotice() {
|
||
if (calibrationNoticeSeen) return true;
|
||
let seen = false;
|
||
try { seen = localStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {}
|
||
if (!seen) {
|
||
try { seen = sessionStorage.getItem(CALIBRATION_NOTICE_KEY) === '1'; } catch (_) {}
|
||
}
|
||
calibrationNoticeSeen = seen;
|
||
return seen;
|
||
}
|
||
|
||
function maybeShowCalibrationNotice() {
|
||
if (!calibrationNotice || !calibrationNoticeBtn) return;
|
||
if (hasSeenCalibrationNotice()) return;
|
||
const close = () => {
|
||
calibrationNotice.style.display = 'none';
|
||
markCalibrationNoticeSeen();
|
||
calibrationNoticeBtn.removeEventListener('click', close);
|
||
calibrationNotice.removeEventListener('click', onBackdrop);
|
||
};
|
||
const onBackdrop = (ev) => {
|
||
if (ev.target === calibrationNotice) close();
|
||
};
|
||
calibrationNoticeBtn.addEventListener('click', close);
|
||
calibrationNotice.addEventListener('click', onBackdrop);
|
||
calibrationNotice.style.display = 'flex';
|
||
}
|
||
|
||
function setView(id){
|
||
const fallbackId = DEFAULT_STYLE;
|
||
const targetId = (id && VIEWS[id]) ? id : fallbackId;
|
||
if (targetId !== 'split-view' && env?.splitPopup) {
|
||
env.splitPopup.open = false;
|
||
env.splitPopup.viewId = null;
|
||
}
|
||
if (targetId !== 'quad-view' && env?.quadPopup) {
|
||
env.quadPopup.open = false;
|
||
env.quadPopup.viewId = null;
|
||
}
|
||
if (targetId !== 'clock' && currentViewId === 'clock') {
|
||
lastViewBeforeClock = null;
|
||
}
|
||
if (currentView && currentView.destroy){
|
||
try { currentView.destroy(viewState); } catch(e){ console.warn('View destroy error:', e); }
|
||
}
|
||
currentView = VIEWS[targetId] || viewGoni;
|
||
currentViewId = targetId;
|
||
viewState = currentView.init ? currentView.init(env) : {};
|
||
if (currentView.resize){
|
||
try { currentView.resize({ rect: env.rect }, viewState); }
|
||
catch(e){ console.warn('View resize error:', e); }
|
||
}
|
||
maybeShowRecorderWarning(targetId);
|
||
requestRender('set-view');
|
||
}
|
||
|
||
function maybeShowRecorderWarning(targetId) {
|
||
if (targetId !== 'recorder') return;
|
||
if (!recWarning || !recWarningAck || !recWarningBtn) return;
|
||
let seen = false;
|
||
try { seen = localStorage.getItem(REC_WARNING_KEY) === '1'; } catch (_) {}
|
||
if (seen) return;
|
||
bindRecorderWarningHandlers();
|
||
recWarningAck.checked = false;
|
||
recWarningBtn.disabled = true;
|
||
recWarning.style.display = 'flex';
|
||
}
|
||
|
||
function bindRecorderWarningHandlers() {
|
||
if (!recWarningAck || !recWarningBtn || recWarningBtn.dataset.bound === '1') return;
|
||
const syncBtn = () => {
|
||
recWarningBtn.disabled = !recWarningAck.checked;
|
||
};
|
||
recWarningAck.addEventListener('change', syncBtn);
|
||
recWarningBtn.addEventListener('click', () => {
|
||
if (!recWarningAck.checked) return;
|
||
try { localStorage.setItem(REC_WARNING_KEY, '1'); } catch (_) {}
|
||
recWarning.style.display = 'none';
|
||
});
|
||
syncBtn();
|
||
recWarningBtn.dataset.bound = '1';
|
||
}
|
||
|
||
// --- Env ---------------------------------------------------------------------
|
||
const audioState = {
|
||
alive: false,
|
||
backendMode: 'phoenix',
|
||
backendLabel: 'Phoenix',
|
||
baseUrl: null,
|
||
nyq: 24000,
|
||
sampleRate: 48000,
|
||
getAnalyser: null,
|
||
getFreqBuffer: null,
|
||
getRtaData: () => audioState.rtaData,
|
||
lastSampleTs: 0,
|
||
lastSignalTs: (typeof performance !== 'undefined' ? performance.now() : Date.now()),
|
||
xyL: null,
|
||
xyR: null,
|
||
xySeq: 0,
|
||
rtaData: null,
|
||
rmsDb: { L: -120, R: -120, mono: -120 },
|
||
updateRtaConfig: null,
|
||
updatePpmConfig: null,
|
||
updateInputOffset: null,
|
||
updateMonoMode: null,
|
||
updateLrDelay: null,
|
||
updatePhoenixGlobalConfig: null,
|
||
};
|
||
|
||
const env = {
|
||
ctx: g,
|
||
rect: { x:0, y:0, w:C.clientWidth, h:C.clientHeight },
|
||
config: CONFIG,
|
||
utils: Object.assign({}, utils, { showErr, hideErr }),
|
||
audio: audioState,
|
||
screensaverWakeTs: 0,
|
||
slots: (viewId) => getSlotsForStyle(viewId),
|
||
getActiveMeterIds: () => getActiveMeterIds(getRenderableStyle()),
|
||
getActivePlotIds: (viewId) => getActivePlotIds(viewId),
|
||
getProcessingProfile: () => buildProcessingProfile(getRenderableStyle()),
|
||
meters: null,
|
||
invalidateMeters: () => { try { meterFacade.invalidateAll?.(); } catch (_) {} },
|
||
requestRender: () => {},
|
||
ui: { showErr, hideErr },
|
||
syncOptionsUI: () => { try { opts.syncUI?.(); } catch (_) {} },
|
||
recorder: recorderState,
|
||
recorderDom: { hud: recorderHud, label: recLabel, start: recStartBtn, stop: recStopBtn, auto: recAutoBtn, list: recList, timer: recTimer },
|
||
screensaver,
|
||
resetSlotsToDefaults: () => resetAllSlotsToDefaults(),
|
||
syncConfigBackedSlots: () => {
|
||
syncConfigBackedSlots();
|
||
refreshSlotEditors();
|
||
},
|
||
buildPresetUiState: () => buildPresetUiState(),
|
||
splitPopup: popupStates.split,
|
||
quadPopup: popupStates.quad,
|
||
switchView: (id) => {
|
||
const target = VALID_STYLES.has(id) ? id : DEFAULT_STYLE;
|
||
if (styleSel) {
|
||
styleSel.value = target;
|
||
styleSel.dispatchEvent(new Event('change'));
|
||
} else {
|
||
style = target;
|
||
setView(target);
|
||
requestRender('switch-view');
|
||
}
|
||
},
|
||
};
|
||
|
||
// Meter pointer routing (für interaktive Slot-Meter wie "Stoppuhr")
|
||
const meterHitRects = [];
|
||
const meters = {
|
||
update: (...args) => meterFacade.update(...args),
|
||
draw: async (ctx, rect, id, config) => {
|
||
if (rect && id) meterHitRects.push({ rect: { ...rect }, id });
|
||
return meterFacade.draw(ctx, rect, id, config);
|
||
},
|
||
pointer: (...args) => meterFacade.pointer?.(...args),
|
||
getState: (...args) => meterFacade.getState?.(...args),
|
||
};
|
||
env.meters = meters;
|
||
|
||
C.addEventListener('pointerdown', async (e) => {
|
||
if (!meterHitRects.length) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
|
||
for (let i = meterHitRects.length - 1; i >= 0; i--) {
|
||
const hit = meterHitRects[i];
|
||
const rect = hit.rect;
|
||
if (!rect) continue;
|
||
if (x < rect.x || x > rect.x + rect.w || y < rect.y || y > rect.y + rect.h) continue;
|
||
const handled = await meterFacade.pointer?.(
|
||
{ type: 'pointerdown', x, y, button: e.button },
|
||
rect,
|
||
hit.id,
|
||
CONFIG,
|
||
);
|
||
if (handled) {
|
||
try { e.preventDefault(); } catch (_) {}
|
||
try { C.setPointerCapture?.(e.pointerId); } catch (_) {}
|
||
C.__meterPointerCapture = { id: hit.id, rect, pointerId: e.pointerId };
|
||
}
|
||
return;
|
||
}
|
||
}, { passive: false });
|
||
|
||
C.addEventListener('pointermove', async (e) => {
|
||
const cap = C.__meterPointerCapture;
|
||
if (!cap) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
const handled = await meterFacade.pointer?.(
|
||
{ type: 'pointermove', x, y, pointerId: e.pointerId },
|
||
cap.rect,
|
||
cap.id,
|
||
CONFIG,
|
||
);
|
||
if (handled) {
|
||
try { e.preventDefault(); } catch (_) {}
|
||
}
|
||
}, { passive: false });
|
||
|
||
const clearMeterPointerCapture = (e) => {
|
||
const cap = C.__meterPointerCapture;
|
||
if (!cap) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e && Number.isFinite(e.clientX) ? (e.clientX - r.left) : NaN;
|
||
const y = e && Number.isFinite(e.clientY) ? (e.clientY - r.top) : NaN;
|
||
meterFacade.pointer?.(
|
||
{ type: e?.type === 'pointercancel' ? 'pointercancel' : 'pointerup', x, y, pointerId: e?.pointerId },
|
||
cap.rect,
|
||
cap.id,
|
||
CONFIG,
|
||
);
|
||
try { C.releasePointerCapture?.(cap.pointerId); } catch (_) {}
|
||
C.__meterPointerCapture = null;
|
||
};
|
||
|
||
C.addEventListener('pointerup', clearMeterPointerCapture, { passive: true });
|
||
C.addEventListener('pointercancel', clearMeterPointerCapture, { passive: true });
|
||
|
||
// Quad-View: Tap/Click in Quadrant -> in Single-View wechseln
|
||
let quadTap = null;
|
||
function pointInRect(x, y, r) {
|
||
if (!r) return false;
|
||
return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
|
||
}
|
||
function isInAnyMeterHitRect(x, y) {
|
||
for (const hit of meterHitRects) {
|
||
const r = hit?.rect;
|
||
if (r && pointInRect(x, y, r)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Split-View: Tap/Click in Linker/Rechter Plot -> in Single-View wechseln / Popup
|
||
let splitTap = null;
|
||
function handleSplitTapUp(x, y) {
|
||
if (document?.body?.classList?.contains('screensaver-active')) return;
|
||
if (isOptionsStyle(style) || currentViewId !== 'split-view') return;
|
||
const hit = viewState?._splitHit;
|
||
if (!hit) return;
|
||
if (isInAnyMeterHitRect(x, y)) return;
|
||
for (const mr of hit.meters || []) {
|
||
if (pointInRect(x, y, mr)) return;
|
||
}
|
||
const plots = hit.plots;
|
||
const left = plots?.left;
|
||
const right = plots?.right;
|
||
const target =
|
||
(left && pointInRect(x, y, left)) ? left.viewId
|
||
: ((right && pointInRect(x, y, right)) ? right.viewId : null);
|
||
if (!target || target === 'none') return;
|
||
|
||
const action = (CONFIG.SPLIT_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch';
|
||
if (action === 'popup') {
|
||
if (env?.splitPopup?.open) {
|
||
env.splitPopup.open = false;
|
||
env.splitPopup.viewId = null;
|
||
return;
|
||
}
|
||
env.splitPopup.open = true;
|
||
env.splitPopup.viewId = target;
|
||
return;
|
||
}
|
||
env.switchView?.(target);
|
||
}
|
||
function handleQuadTapUp(x, y) {
|
||
if (document?.body?.classList?.contains('screensaver-active')) return;
|
||
if (isOptionsStyle(style) || currentViewId !== 'quad-view') return;
|
||
const hit = viewState?._quadHit;
|
||
if (!hit) return;
|
||
if (y < (hit.contentY ?? 0)) return;
|
||
const action = (CONFIG.QUAD_VIEW_TAP_ACTION === 'popup') ? 'popup' : 'switch';
|
||
if (action === 'popup') {
|
||
if (env?.quadPopup?.open && hit.popupBox) {
|
||
// Popup ist offen: Tap innerhalb/außerhalb schließt
|
||
env.quadPopup.open = false;
|
||
env.quadPopup.viewId = null;
|
||
return;
|
||
}
|
||
if (isInAnyMeterHitRect(x, y)) return;
|
||
for (const mr of hit.meters || []) {
|
||
if (pointInRect(x, y, mr)) return;
|
||
}
|
||
const q = (hit.quadrants || []).find((it) => pointInRect(x, y, it?.rect));
|
||
const target = q?.viewId;
|
||
if (!target || target === 'none') return;
|
||
if (env?.quadPopup) {
|
||
env.quadPopup.open = true;
|
||
env.quadPopup.viewId = target;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Default: direkt wechseln
|
||
if (isInAnyMeterHitRect(x, y)) return;
|
||
for (const mr of hit.meters || []) {
|
||
if (pointInRect(x, y, mr)) return;
|
||
}
|
||
const q = (hit.quadrants || []).find((it) => pointInRect(x, y, it?.rect));
|
||
const target = q?.viewId;
|
||
if (!target || target === 'none') return;
|
||
env.switchView?.(target);
|
||
}
|
||
|
||
C.addEventListener('pointerdown', (e) => {
|
||
// Popup-Modus: Klick schließt sofort (Capture, damit Meter/Views nicht reagieren)
|
||
if (document?.body?.classList?.contains('screensaver-active')) return;
|
||
if (isOptionsStyle(style) || currentViewId !== 'split-view') return;
|
||
if (CONFIG.SPLIT_VIEW_TAP_ACTION !== 'popup') return;
|
||
if (!env?.splitPopup?.open) return;
|
||
env.splitPopup.open = false;
|
||
env.splitPopup.viewId = null;
|
||
try { e.preventDefault?.(); } catch (_) {}
|
||
try { e.stopPropagation?.(); } catch (_) {}
|
||
}, { capture: true, passive: false });
|
||
|
||
C.addEventListener('pointerdown', (e) => {
|
||
if (isOptionsStyle(style) || currentViewId !== 'split-view') return;
|
||
if (CONFIG.SPLIT_VIEW_TAP_ACTION === 'popup' && env?.splitPopup?.open) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
if (isInAnyMeterHitRect(x, y)) return;
|
||
splitTap = { pointerId: e.pointerId, x0: x, y0: y, maxD2: 0, t0: performance.now() };
|
||
}, { passive: true });
|
||
|
||
C.addEventListener('pointermove', (e) => {
|
||
if (!splitTap || splitTap.pointerId !== e.pointerId) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
const dx = x - splitTap.x0;
|
||
const dy = y - splitTap.y0;
|
||
splitTap.maxD2 = Math.max(splitTap.maxD2, dx * dx + dy * dy);
|
||
}, { passive: true });
|
||
|
||
C.addEventListener('pointerup', (e) => {
|
||
if (!splitTap || splitTap.pointerId !== e.pointerId) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
const dt = performance.now() - splitTap.t0;
|
||
const moved = splitTap.maxD2 > (10 * 10);
|
||
splitTap = null;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
if (moved || dt > 650) return;
|
||
handleSplitTapUp(x, y);
|
||
}, { passive: true });
|
||
|
||
C.addEventListener('pointerdown', (e) => {
|
||
// Popup-Modus: Klick außerhalb schließt sofort (Capture, damit Meter/Quad nicht reagieren)
|
||
if (document?.body?.classList?.contains('screensaver-active')) return;
|
||
if (isOptionsStyle(style) || currentViewId !== 'quad-view') return;
|
||
if (CONFIG.QUAD_VIEW_TAP_ACTION !== 'popup') return;
|
||
if (!env?.quadPopup?.open) return;
|
||
const hit = viewState?._quadHit;
|
||
const box = hit?.popupBox;
|
||
if (!box) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
if (y < (hit.contentY ?? 0)) return;
|
||
if (pointInRect(x, y, box)) return;
|
||
env.quadPopup.open = false;
|
||
env.quadPopup.viewId = null;
|
||
try { e.preventDefault?.(); } catch (_) {}
|
||
try { e.stopPropagation?.(); } catch (_) {}
|
||
}, { capture: true, passive: false });
|
||
|
||
C.addEventListener('pointerdown', (e) => {
|
||
if (isOptionsStyle(style) || currentViewId !== 'quad-view') return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
if (isInAnyMeterHitRect(x, y)) return;
|
||
quadTap = { pointerId: e.pointerId, x0: x, y0: y, maxD2: 0, t0: performance.now() };
|
||
}, { passive: true });
|
||
|
||
C.addEventListener('pointermove', (e) => {
|
||
if (!quadTap || quadTap.pointerId !== e.pointerId) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
const dx = x - quadTap.x0;
|
||
const dy = y - quadTap.y0;
|
||
quadTap.maxD2 = Math.max(quadTap.maxD2, dx * dx + dy * dy);
|
||
}, { passive: true });
|
||
|
||
C.addEventListener('pointerup', (e) => {
|
||
if (!quadTap || quadTap.pointerId !== e.pointerId) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
const dt = performance.now() - quadTap.t0;
|
||
const moved = quadTap.maxD2 > (10 * 10);
|
||
quadTap = null;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
if (moved || dt > 650) return;
|
||
handleQuadTapUp(x, y);
|
||
}, { passive: true });
|
||
|
||
C.addEventListener('wheel', async (e) => {
|
||
if (!meterHitRects.length) return;
|
||
const r = C.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
for (let i = meterHitRects.length - 1; i >= 0; i--) {
|
||
const hit = meterHitRects[i];
|
||
const rect = hit.rect;
|
||
if (!rect) continue;
|
||
if (x < rect.x || x > rect.x + rect.w || y < rect.y || y > rect.y + rect.h) continue;
|
||
const handled = await meterFacade.pointer?.(
|
||
{ type: 'wheel', x, y, deltaY: e.deltaY, deltaX: e.deltaX },
|
||
rect,
|
||
hit.id,
|
||
CONFIG,
|
||
);
|
||
if (handled) {
|
||
try { e.preventDefault(); } catch (_) {}
|
||
}
|
||
return;
|
||
}
|
||
}, { passive: false });
|
||
|
||
['mousemove','keydown','click','touchstart'].forEach((ev) => {
|
||
addEventListener(ev, () => {
|
||
screensaver?.markActivity?.();
|
||
void maybeRecoverAudio(`activity:${ev}`);
|
||
}, { passive: true });
|
||
});
|
||
|
||
if (gainSel) {
|
||
gainSel.addEventListener('change', () => {
|
||
const raw = Number(gainSel.value);
|
||
const val = Number.isFinite(raw) ? raw : 0;
|
||
handleRealtimeGainSelection(val);
|
||
});
|
||
}
|
||
if (peakHistScrollSel) {
|
||
peakHistScrollSel.addEventListener('change', () => {
|
||
handlePeakHistScrollSelection(peakHistScrollSel.value);
|
||
});
|
||
}
|
||
if (resetPeakBtn) {
|
||
resetPeakBtn.addEventListener('click', () => {
|
||
try {
|
||
if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') {
|
||
window.resetRealTimeAnalyzerPeakHold();
|
||
}
|
||
} catch (_) {}
|
||
});
|
||
}
|
||
if (waveWindowSel) {
|
||
waveWindowSel.addEventListener('change', () => {
|
||
handleWaveWindowSelection(Number(waveWindowSel.value));
|
||
});
|
||
}
|
||
if (waveModeSel) {
|
||
waveModeSel.addEventListener('change', () => {
|
||
handleWaveModeSelection(waveModeSel.value);
|
||
});
|
||
}
|
||
if (rtaBpoSel) {
|
||
rtaBpoSel.addEventListener('change', () => {
|
||
handleRtaBpoSelection(rtaBpoSel.value);
|
||
});
|
||
}
|
||
if (recTargetSel) {
|
||
recTargetSel.addEventListener('change', () => {
|
||
recorderState.target = CONFIG.RECORD_TARGET || ((recTargetSel.value === 'analyzer' || recTargetSel.value === 'volumio') ? recTargetSel.value : 'download');
|
||
CONFIG.RECORD_TARGET = recorderState.target;
|
||
if (recorderState.target === 'analyzer' || recorderState.target === 'volumio') {
|
||
CONFIG.RECORD_PI_TARGET = recorderState.target;
|
||
}
|
||
try { saveConfig(); } catch (_) {}
|
||
updateRecorderHud();
|
||
});
|
||
}
|
||
if (recStartBtn) {
|
||
recStartBtn.addEventListener('click', () => { void startRecording(); });
|
||
}
|
||
if (recStopBtn) {
|
||
recStopBtn.addEventListener('click', () => { stopRecording(); });
|
||
}
|
||
if (recAutoBtn) {
|
||
recAutoBtn.addEventListener('click', () => {
|
||
setRecorderAutoEnabled(!recorderState.autoEnabled);
|
||
});
|
||
}
|
||
updateRecorderHud();
|
||
|
||
function handleRealtimeGainSelection(value){
|
||
let val = Number(value);
|
||
if (!Number.isFinite(val)) val = 0;
|
||
CONFIG.RTA_DISPLAY_GAIN_IIR_DB = val;
|
||
saveConfig();
|
||
if (gainSel) {
|
||
const setVal = REALTIME_GAIN_CHOICES.includes(val) ? val : 0;
|
||
gainSel.value = String(setVal);
|
||
}
|
||
const slider = document.getElementById('opt_rtaDisplayGainIir');
|
||
if (slider) slider.value = val;
|
||
const label = document.getElementById('val_rtaDisplayGainIir');
|
||
if (label) label.textContent = `${val} dB`;
|
||
try { env.audio.updateRtaConfig?.(); } catch (_) {}
|
||
}
|
||
|
||
function handleWaveWindowSelection(value){
|
||
const nearest = getNearestWaveWindow(value);
|
||
CONFIG.WAVEFORM_WINDOW_SEC = nearest;
|
||
saveConfig();
|
||
if (waveWindowSel) {
|
||
waveWindowSel.value = String(nearest);
|
||
}
|
||
const input = document.getElementById('opt_waveWindow');
|
||
if (input) {
|
||
input.value = nearest;
|
||
}
|
||
}
|
||
|
||
function handleWaveModeSelection(value){
|
||
const mode = normalizeWaveMode(value);
|
||
CONFIG.WAVEFORM_MODE = mode;
|
||
saveConfig();
|
||
if (waveModeSel) {
|
||
waveModeSel.value = mode;
|
||
}
|
||
const select = document.getElementById('opt_waveMode');
|
||
if (select) {
|
||
select.value = mode;
|
||
}
|
||
}
|
||
|
||
function handlePeakHistScrollSelection(value){
|
||
const val = normalizePeakHistScroll(value);
|
||
CONFIG.PEAK_HISTORY_SCROLL_MODE = val;
|
||
saveConfig();
|
||
if (peakHistScrollSel) {
|
||
peakHistScrollSel.value = String(val);
|
||
}
|
||
const optSel = document.getElementById('opt_peakHistScroll');
|
||
if (optSel) optSel.value = String(val);
|
||
}
|
||
|
||
function handleRtaBpoSelection(value){
|
||
const mode = normalizeRtaBpo(value);
|
||
CONFIG.RTA_BPO_MODE = mode;
|
||
saveConfig();
|
||
if (rtaBpoSel) {
|
||
rtaBpoSel.value = mode;
|
||
}
|
||
const select = document.getElementById('opt_rtaBpo');
|
||
if (select) {
|
||
select.value = mode;
|
||
}
|
||
try { env.audio.updateRtaConfig?.(); } catch (_) {}
|
||
}
|
||
|
||
function getNearestWaveWindow(value){
|
||
const num = Number(value);
|
||
const target = Math.max(0.05, Math.min(15, Number.isFinite(num) ? num : (CONFIG.WAVEFORM_WINDOW_SEC || 1)));
|
||
return WAVE_WINDOW_CHOICES.reduce((prev, curr) => {
|
||
return Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev;
|
||
}, WAVE_WINDOW_CHOICES[0]);
|
||
}
|
||
|
||
function normalizeWaveMode(value){
|
||
if (value === 'overlay') return 'overlay';
|
||
if (value === 'diff') return 'diff';
|
||
return 'stacked';
|
||
}
|
||
|
||
function normalizeRtaBpo(value){
|
||
const val = typeof value === 'string' ? value : '';
|
||
return RTA_BPO_CHOICES.some(([key]) => key === val) ? val : '1_6';
|
||
}
|
||
|
||
function normalizePeakHistScroll(value){
|
||
const num = Number(value);
|
||
const found = PEAK_HISTORY_SCROLL_CHOICES.find((v) => Math.abs(v - num) < 1e-9);
|
||
return found ?? 1;
|
||
}
|
||
|
||
function formatTimerFull(ms){
|
||
if (!Number.isFinite(ms)) return '00:00:00:00';
|
||
const totalHund = Math.max(0, Math.floor(ms / 10));
|
||
const hund = totalHund % 100;
|
||
const totalSec = Math.floor(totalHund / 100);
|
||
const sec = totalSec % 60;
|
||
const totalMin = Math.floor(totalSec / 60);
|
||
const min = totalMin % 60;
|
||
const hrs = Math.floor(totalMin / 60);
|
||
const two = (n) => String(n).padStart(2, '0');
|
||
return `${two(hrs)}:${two(min)}:${two(sec)}:<span class="hund">${two(hund)}</span>`;
|
||
}
|
||
|
||
function stopRecorderTimer() {
|
||
if (recorderState.timerRaf) {
|
||
cancelAnimationFrame(recorderState.timerRaf);
|
||
recorderState.timerRaf = null;
|
||
}
|
||
}
|
||
|
||
function stopRecorderAutoMonitor() {
|
||
if (recorderState.autoMonitorRaf) {
|
||
cancelAnimationFrame(recorderState.autoMonitorRaf);
|
||
recorderState.autoMonitorRaf = null;
|
||
}
|
||
}
|
||
|
||
function tickRecorderAuto() {
|
||
if (!recorderState.autoEnabled) {
|
||
stopRecorderAutoMonitor();
|
||
return;
|
||
}
|
||
const now = performance.now();
|
||
const level = Number.isFinite(env?.audio?.rmsDb?.mono) ? env.audio.rmsDb.mono : -120;
|
||
const hasSignal = level > getRecorderAutoThresholdDbfs();
|
||
const busy = recorderState.status === 'starting';
|
||
const recording = recorderState.status === 'recording' && !!recorderState.activeSession;
|
||
|
||
if (recording) {
|
||
if (hasSignal) {
|
||
recorderState.autoSilenceSince = 0;
|
||
} else if (!recorderState.autoPendingStop) {
|
||
if (!recorderState.autoSilenceSince) recorderState.autoSilenceSince = now;
|
||
if ((now - recorderState.autoSilenceSince) >= getRecorderAutoSplitGapMs()) {
|
||
recorderState.autoPendingStop = true;
|
||
stopRecording({ keepAuto: true });
|
||
}
|
||
}
|
||
} else if (!busy) {
|
||
recorderState.autoSilenceSince = 0;
|
||
if (hasSignal && !recorderState.autoPendingStart) {
|
||
if (canStartRecorderWithoutInputStream()) {
|
||
recorderState.autoPendingStart = true;
|
||
void startRecording({ keepAuto: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
recorderState.autoMonitorRaf = requestAnimationFrame(tickRecorderAuto);
|
||
}
|
||
|
||
function setRecorderAutoEnabled(enabled) {
|
||
const next = !!enabled;
|
||
recorderState.autoEnabled = next;
|
||
recorderState.autoSilenceSince = 0;
|
||
recorderState.autoPendingStart = false;
|
||
recorderState.autoPendingStop = false;
|
||
if (next) {
|
||
if (!recorderState.autoMonitorRaf) {
|
||
recorderState.autoMonitorRaf = requestAnimationFrame(tickRecorderAuto);
|
||
}
|
||
} else {
|
||
stopRecorderAutoMonitor();
|
||
}
|
||
updateRecorderHud();
|
||
}
|
||
|
||
function tickRecorderTimer() {
|
||
if (!recorderState || recorderState.status !== 'recording') return;
|
||
const now = performance.now();
|
||
const durationMs = now - (recorderState.startTs || now);
|
||
if (recTimer) recTimer.innerHTML = formatTimerFull(durationMs);
|
||
recorderState.timerRaf = requestAnimationFrame(tickRecorderTimer);
|
||
}
|
||
|
||
function getRecordingExtension(mimeType = '') {
|
||
const type = String(mimeType || '').toLowerCase();
|
||
if (type.includes('ogg')) return 'ogg';
|
||
if (type.includes('mpeg')) return 'mp3';
|
||
if (type.includes('wav')) return 'wav';
|
||
return 'webm';
|
||
}
|
||
|
||
function canUseNativeWavRecorder() {
|
||
return typeof env?.audio?.startWavCapture === 'function'
|
||
&& typeof env?.audio?.stopWavCapture === 'function';
|
||
}
|
||
|
||
function canStartRecorderWithoutInputStream() {
|
||
return canUseNativeWavRecorder();
|
||
}
|
||
|
||
async function finalizeRecordingSession(session, resultPromise) {
|
||
const fallbackType = session?.mimeType || 'audio/webm';
|
||
let finalBlob = null;
|
||
let downloadName = '';
|
||
let sessionError = '';
|
||
try {
|
||
const result = await resultPromise;
|
||
finalBlob = result?.blob || null;
|
||
sessionError = result?.error || '';
|
||
downloadName = result?.downloadName || (finalBlob ? buildRecordingFilename(getRecordingExtension(result?.mimeType || finalBlob.type || fallbackType)) : '');
|
||
if (!sessionError && !finalBlob) sessionError = 'Aufnahme konnte nicht erstellt werden';
|
||
if (!sessionError && finalBlob) {
|
||
addRecordingToHistory(finalBlob, downloadName);
|
||
}
|
||
} catch (e) {
|
||
sessionError = e?.message || 'Aufnahme konnte nicht erstellt werden';
|
||
}
|
||
|
||
recorderState.lastDurationMs = Math.max(0, performance.now() - session.startTs);
|
||
recorderState.processingRecorderSlots = recorderState.processingRecorderSlots.filter((slot) => slot !== session.slot);
|
||
recorderState.processingJobs = Math.max(0, recorderState.processingJobs - 1);
|
||
recorderState.error = sessionError || recorderState.error;
|
||
if (recorderState.activeSession?.id !== session.id) {
|
||
recorderState.status = recorderState.error
|
||
? 'error'
|
||
: (recorderState.activeSession ? 'recording' : (recorderState.processingJobs > 0 ? 'processing' : 'stopped'));
|
||
updateRecorderHud();
|
||
}
|
||
|
||
if (!recorderState.activeSession) {
|
||
stopRecorderTimer();
|
||
}
|
||
recorderState.autoPendingStart = false;
|
||
recorderState.autoPendingStop = false;
|
||
if (!recorderState.activeSession) {
|
||
recorderState.status = recorderState.error
|
||
? 'error'
|
||
: (recorderState.processingJobs > 0 ? 'processing' : 'stopped');
|
||
}
|
||
updateRecorderHud();
|
||
}
|
||
|
||
function updateRecorderHud(){
|
||
const desiredTarget = (CONFIG.RECORD_TARGET === 'analyzer' || CONFIG.RECORD_TARGET === 'volumio') ? CONFIG.RECORD_TARGET : 'download';
|
||
recorderState.target = desiredTarget;
|
||
if (recTargetSel && recTargetSel.value !== recorderState.target) {
|
||
recTargetSel.value = recorderState.target;
|
||
}
|
||
const recording = recorderState.status === 'recording' && !!recorderState.activeSession;
|
||
const processing = !recording && recorderState.processingJobs > 0;
|
||
const busy = recorderState.status === 'starting';
|
||
if (recLabel) {
|
||
recLabel.classList.toggle('blink', recording);
|
||
}
|
||
if (recStartBtn) recStartBtn.disabled = recording || busy || recorderState.autoEnabled || (!recorderState.autoEnabled && processing);
|
||
if (recStopBtn) recStopBtn.disabled = !recording && !recorderState.autoEnabled;
|
||
if (recAutoBtn) {
|
||
recAutoBtn.disabled = busy;
|
||
recAutoBtn.classList.toggle('btn-active', recorderState.autoEnabled && !recording);
|
||
recAutoBtn.classList.toggle('rec-auto-armed', recorderState.autoEnabled && !recording);
|
||
recAutoBtn.classList.toggle('rec-auto-recording', recorderState.autoEnabled && recording);
|
||
recAutoBtn.textContent = recorderState.autoEnabled ? 'Auto AN' : 'Auto';
|
||
}
|
||
if (recProcessing) recProcessing.style.display = (processing && !recorderState.autoEnabled) ? 'flex' : 'none';
|
||
const durationMs = recording
|
||
? (performance.now() - (recorderState.startTs || performance.now()))
|
||
: recorderState.lastDurationMs || 0;
|
||
if (recTimer) {
|
||
recTimer.innerHTML = formatTimerFull(durationMs);
|
||
}
|
||
renderRecordingList();
|
||
}
|
||
|
||
function buildRecordingFilename(ext = 'webm') {
|
||
const ts = new Date();
|
||
const stamp = ts.toISOString().replace(/[:.]/g, '-');
|
||
return `recording-${stamp}.${ext}`;
|
||
}
|
||
|
||
async function startRecording(opts = {}){
|
||
const allowParallelFinalize = !!opts.keepAuto;
|
||
if (recorderState.activeSession || recorderState.status === 'starting') return;
|
||
if (!allowParallelFinalize && recorderState.processingJobs > 0) return;
|
||
if (!opts.keepAuto && recorderState.autoEnabled) {
|
||
setRecorderAutoEnabled(false);
|
||
}
|
||
stopRecorderTimer();
|
||
recorderState.status = 'starting';
|
||
recorderState.error = '';
|
||
const rawOutFormat = String(CONFIG.RECORD_OUTPUT_FORMAT || 'wav').toLowerCase();
|
||
const outFormat = rawOutFormat === 'webm' ? 'webm' : (rawOutFormat === 'mp3' ? 'mp3' : 'wav');
|
||
if (!canStartRecorderWithoutInputStream()) {
|
||
recorderState.error = 'Kein Audio-Stream verfügbar';
|
||
recorderState.status = 'idle';
|
||
recorderState.autoPendingStart = false;
|
||
updateRecorderHud();
|
||
showErr('Recorder: Kein Audio-Stream verfügbar (Audio starten?)');
|
||
return;
|
||
}
|
||
const session = {
|
||
id: recorderState.nextSessionId++,
|
||
kind: outFormat,
|
||
recorder: null,
|
||
mimeType: outFormat === 'mp3' ? 'audio/mpeg' : (outFormat === 'wav' ? 'audio/wav' : 'audio/webm'),
|
||
chunks: [],
|
||
startTs: performance.now(),
|
||
slot: allowParallelFinalize ? recorderState.nextRecorderSlot : 'A',
|
||
};
|
||
if (allowParallelFinalize) {
|
||
recorderState.nextRecorderSlot = (session.slot === 'A') ? 'B' : 'A';
|
||
} else {
|
||
recorderState.nextRecorderSlot = 'B';
|
||
}
|
||
|
||
recorderState.activeSession = session;
|
||
recorderState.activeRecorderSlot = session.slot;
|
||
recorderState.lastDurationMs = 0;
|
||
try {
|
||
await env.audio.startWavCapture(session.id);
|
||
} catch (e) {
|
||
if (recorderState.activeSession?.id === session.id) {
|
||
recorderState.activeSession = null;
|
||
recorderState.activeRecorderSlot = null;
|
||
}
|
||
recorderState.error = e?.message || 'Phoenix-Recorder nicht verfügbar';
|
||
recorderState.status = 'error';
|
||
recorderState.autoPendingStart = false;
|
||
updateRecorderHud();
|
||
return;
|
||
}
|
||
recorderState.startTs = session.startTs;
|
||
recorderState.status = 'recording';
|
||
recorderState.autoPendingStart = false;
|
||
recorderState.autoSilenceSince = 0;
|
||
updateRecorderHud();
|
||
tickRecorderTimer();
|
||
}
|
||
|
||
function stopRecording(opts = {}){
|
||
if (!opts.keepAuto && recorderState.autoEnabled) {
|
||
setRecorderAutoEnabled(false);
|
||
}
|
||
const session = recorderState.activeSession;
|
||
if ((session?.kind === 'wav' || session?.kind === 'mp3' || session?.kind === 'webm') && recorderState.status === 'recording') {
|
||
recorderState.processingJobs += 1;
|
||
if (!recorderState.processingRecorderSlots.includes(session.slot)) {
|
||
recorderState.processingRecorderSlots = [...recorderState.processingRecorderSlots, session.slot];
|
||
}
|
||
recorderState.activeSession = null;
|
||
recorderState.activeRecorderSlot = null;
|
||
recorderState.status = recorderState.processingJobs > 0 ? 'processing' : 'stopped';
|
||
updateRecorderHud();
|
||
const finalizePromise = env.audio.stopWavCapture(session.id, session.kind, {
|
||
mp3BitrateKbps: Number(CONFIG.RECORD_MP3_BITRATE_KBPS) || 192,
|
||
}).then((capture) => ({
|
||
blob: capture?.blob || null,
|
||
mimeType: capture?.mimeType || 'audio/wav',
|
||
downloadName: buildRecordingFilename(getRecordingExtension(capture?.mimeType || capture?.blob?.type || session.mimeType || 'audio/wav')),
|
||
}));
|
||
void finalizeRecordingSession(session, finalizePromise);
|
||
}
|
||
recorderState.autoPendingStart = false;
|
||
stopRecorderTimer();
|
||
}
|
||
|
||
function addRecordingToHistory(blob, name) {
|
||
if (!blob) return;
|
||
const url = URL.createObjectURL(blob);
|
||
recorderState.history = recorderState.history || [];
|
||
const entry = { name, url, downloadRequested: false, released: false, saved: false, saving: false, savedPath: '' };
|
||
recorderState.history.unshift(entry);
|
||
while (recorderState.history.length > RECORDER_HISTORY_LIMIT) {
|
||
const old = recorderState.history.pop();
|
||
try { if (old?.url) URL.revokeObjectURL(old.url); } catch (_) {}
|
||
}
|
||
if (CONFIG.RECORD_AUTO_DOWNLOAD === true) {
|
||
void saveRecordingEntry(entry, { rerender: false }).finally(() => {
|
||
renderRecordingList();
|
||
});
|
||
}
|
||
renderRecordingList();
|
||
}
|
||
|
||
function removeRecordingFromHistory(entry) {
|
||
if (!recorderState.history) return;
|
||
recorderState.history = recorderState.history.filter((e) => e !== entry);
|
||
try { if (entry?.url) URL.revokeObjectURL(entry.url); } catch (_) {}
|
||
if (recorderState.history.length === 0) {
|
||
recorderState.lastDurationMs = 0;
|
||
stopRecorderTimer();
|
||
if (recTimer) recTimer.innerHTML = formatTimerFull(0);
|
||
}
|
||
renderRecordingList();
|
||
}
|
||
|
||
function sanitizeGoniGain(val){
|
||
let num = Number(val);
|
||
if (!Number.isFinite(num)) num = 0;
|
||
if (num > GONIO_GAIN_MAX_DB) num = GONIO_GAIN_MAX_DB;
|
||
if (num < GONIO_GAIN_MIN_DB) num = GONIO_GAIN_MIN_DB;
|
||
return Math.round(num / 5) * 5;
|
||
}
|
||
|
||
|
||
function formatWaveWindowLabel(seconds){
|
||
if (seconds >= 1) {
|
||
return `${seconds} s`;
|
||
}
|
||
return `${Math.round(seconds * 1000)} ms`;
|
||
}
|
||
|
||
function updateRect(){
|
||
const w = C.clientWidth;
|
||
const h = C.clientHeight;
|
||
if (w === prevCanvasWidth && h === prevCanvasHeight) return;
|
||
prevCanvasWidth = w;
|
||
prevCanvasHeight = h;
|
||
env.rect.w = w;
|
||
env.rect.h = h;
|
||
if (currentView && currentView.resize){
|
||
try { currentView.resize({ rect: env.rect }, viewState); }
|
||
catch(e){ console.warn('View resize error:', e); }
|
||
}
|
||
}
|
||
|
||
// Jetzt, wo env existiert: auf Window-Resize auch die View aktualisieren
|
||
addEventListener('resize', () => {
|
||
try { updateRect(); }
|
||
catch (e) { console.warn('Deferred resize update error:', e); }
|
||
});
|
||
|
||
// --- Audio -------------------------------------------------------------------
|
||
function delay(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function bootAudio(force = false){
|
||
const retryPlan = force ? [0, 1500, 3500, 7000] : [0, 1500, 3500, 7000];
|
||
let ok = false;
|
||
for (let i = 0; i < retryPlan.length; i++) {
|
||
const waitMs = retryPlan[i];
|
||
if (waitMs > 0) await delay(waitMs);
|
||
ok = force ? await reloadAudio(env) : await initAudio(env);
|
||
if (ok) break;
|
||
console.warn(`Audio init attempt ${i + 1}/${retryPlan.length} failed`);
|
||
}
|
||
if (!ok) showErr('Audio init fehlgeschlagen.'); else hideErr();
|
||
return ok;
|
||
}
|
||
|
||
// --- Hintergrund / Lost Overlay ---------------------------------------------
|
||
function drawBG(){
|
||
const bg = getCachedBackgroundLayer();
|
||
if (bg) {
|
||
g.drawImage(bg, 0, 0, C.clientWidth, C.clientHeight);
|
||
return;
|
||
}
|
||
g.fillStyle = 'rgba(5,5,11,0.75)';
|
||
g.fillRect(0,0,C.clientWidth,C.clientHeight);
|
||
}
|
||
|
||
let backgroundLayerCanvas = null;
|
||
let backgroundLayerKey = '';
|
||
|
||
function getCachedBackgroundLayer() {
|
||
const w = C.clientWidth | 0;
|
||
const h = C.clientHeight | 0;
|
||
if (w <= 0 || h <= 0) return null;
|
||
const key = `${w}x${h}`;
|
||
if (backgroundLayerCanvas && backgroundLayerKey === key) {
|
||
return backgroundLayerCanvas;
|
||
}
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
const ctx = canvas.getContext('2d', { alpha: true });
|
||
if (!ctx) return null;
|
||
ctx.fillStyle = 'rgba(5,5,11,0.75)';
|
||
ctx.fillRect(0, 0, w, h);
|
||
backgroundLayerCanvas = canvas;
|
||
backgroundLayerKey = key;
|
||
return backgroundLayerCanvas;
|
||
}
|
||
|
||
function drawAudioLostOverlay(){
|
||
if (!audioLost(env)) return;
|
||
const backendLabel = String(env.audio?.backendLabel || 'Audio');
|
||
g.save();
|
||
g.fillStyle = 'rgba(0,0,0,0.55)';
|
||
g.fillRect(0,0,C.clientWidth,C.clientHeight);
|
||
g.fillStyle = '#ffd6d6';
|
||
g.textAlign = 'center';
|
||
g.font = 'bold 18px ui-monospace, monospace';
|
||
g.fillText(`${backendLabel} lost – Quelle prüfen`, C.clientWidth/2, C.clientHeight/2);
|
||
g.textAlign = 'start';
|
||
g.restore();
|
||
}
|
||
|
||
function drawOptionsPanelBackdrop(){
|
||
if (style !== 'options-panel') return;
|
||
g.save();
|
||
g.fillStyle = 'rgba(8, 10, 18, 0.5)';
|
||
g.fillRect(0,0,C.clientWidth,C.clientHeight);
|
||
g.restore();
|
||
}
|
||
|
||
// --- Loop (FPS-Kontrolle) ----------------------------------------------------
|
||
function desiredFpsForStyle(renderStyle) {
|
||
return 60;
|
||
}
|
||
|
||
let lastFrameTime = 0;
|
||
let prevCanvasWidth = 0;
|
||
let prevCanvasHeight = 0;
|
||
let prevAudioLost = null;
|
||
let prevSaverActive = false;
|
||
let audioRecoverInFlight = false;
|
||
let lastAudioRecoverAt = 0;
|
||
let renderDirty = true;
|
||
let lastRenderedAudioSeq = 0;
|
||
|
||
function requestRender(reason = 'ui') {
|
||
renderDirty = true;
|
||
env.__lastRenderReason = reason;
|
||
}
|
||
|
||
env.requestRender = requestRender;
|
||
|
||
async function maybeRecoverAudio(reason) {
|
||
if (audioRecoverInFlight) return false;
|
||
if (document.hidden) return false;
|
||
if (!audioLost(env)) return false;
|
||
const now = performance.now();
|
||
if (now - lastAudioRecoverAt < 5000) return false;
|
||
lastAudioRecoverAt = now;
|
||
audioRecoverInFlight = true;
|
||
try {
|
||
await new Promise((r) => setTimeout(r, 800));
|
||
if (audioLost(env)) {
|
||
console.warn(`Audio lost after ${reason}; reloading audio…`);
|
||
await bootAudio(true);
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
console.warn('Audio recover failed:', e);
|
||
return false;
|
||
} finally {
|
||
audioRecoverInFlight = false;
|
||
}
|
||
}
|
||
|
||
async function loop(now){
|
||
const elapsed = now - lastFrameTime;
|
||
const audioOk = env.audio.alive && !audioLost(env);
|
||
const renderStyle = getRenderableStyle();
|
||
try { env.audio?.updateProcessingConfig?.(buildProcessingProfile(renderStyle)); } catch (_) {}
|
||
const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20);
|
||
const target = targetMs;
|
||
const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
|
||
const audioDirty = currentAudioSeq > lastRenderedAudioSeq;
|
||
const shouldRenderFrame =
|
||
renderDirty ||
|
||
audioDirty ||
|
||
!audioOk ||
|
||
style === 'options-panel' ||
|
||
style === 'recorder' ||
|
||
renderStyle === 'clock';
|
||
if (elapsed >= target){
|
||
lastFrameTime = now - (elapsed % targetMs);
|
||
try{
|
||
updateRect();
|
||
meterHitRects.length = 0;
|
||
const lostNow = audioLost(env);
|
||
if (lostNow) {
|
||
// Don't wait for an edge transition; after long idle the app may already be in a lost state.
|
||
void maybeRecoverAudio('lost');
|
||
}
|
||
prevAudioLost = lostNow;
|
||
const saverActive = screensaver.updateAndRender(g, env.rect, now, elapsed);
|
||
if (prevSaverActive && !saverActive) {
|
||
env.screensaverWakeTs = now;
|
||
requestRender('screensaver-wake');
|
||
void maybeRecoverAudio('screensaver-wake');
|
||
}
|
||
prevSaverActive = saverActive;
|
||
if (!saverActive && shouldRenderFrame) {
|
||
meterFacade.setFrameStamp?.(++frameCounter);
|
||
drawBG();
|
||
if (!currentView || currentViewId !== renderStyle) setView(renderStyle);
|
||
if (currentView.render) await currentView.render(env, viewState);
|
||
drawOptionsPanelBackdrop();
|
||
drawAudioLostOverlay();
|
||
renderDirty = false;
|
||
lastRenderedAudioSeq = currentAudioSeq;
|
||
}
|
||
} catch(e){
|
||
showErr('Render error: ' + (e?.message || String(e)));
|
||
console.error('Render loop error:', e);
|
||
}
|
||
}
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
// --- Start -------------------------------------------------------------------
|
||
(async function boot(){
|
||
try{
|
||
const start = async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||
const layoutParam = (() => {
|
||
try {
|
||
return new URLSearchParams(globalThis?.location?.search || '').get('layout') || '';
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
})();
|
||
if (layoutParam) {
|
||
try {
|
||
const result = await loadLayoutPreset(layoutParam);
|
||
if (result) {
|
||
applyPresetUiState(result?.uiState || result?.snapshot?.__PHOENIX_UI_STATE || null);
|
||
const rawStyle = String(result?.uiState?.style || CONFIG.UI_LAST_STYLE || '');
|
||
const fallbackStyle = String(result?.uiState?.lastNonOptionStyle || CONFIG.UI_LAST_NON_OPTION_STYLE || DEFAULT_STYLE);
|
||
const targetStyle = (rawStyle === 'options-panel' || rawStyle === 'options') ? fallbackStyle : (rawStyle || fallbackStyle);
|
||
if (VALID_STYLES.has(targetStyle)) {
|
||
applyStyleSelection(targetStyle, { persistStorage: true, persistConfig: true });
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn('Layout preset load error:', err);
|
||
}
|
||
}
|
||
await bootAudio();
|
||
applyStyleSelection(style, { persistStorage: false, persistConfig: false });
|
||
requestRender('boot');
|
||
requestAnimationFrame(loop);
|
||
};
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', start);
|
||
} else {
|
||
await start();
|
||
}
|
||
saveSlotState(slotsState);
|
||
} catch(e){
|
||
showErr('Boot error: ' + (e?.message || String(e)));
|
||
console.error('Boot error:', e);
|
||
}
|
||
})();
|