|null}
+ */
+ getHarmonicaPlayableNotes() {
+ const caps = this.selectedDeviceCapabilities;
+ const gmProgram = caps?.gm_program ?? this.selectedDevice?.gm_program ?? null;
+ if (gmProgram !== 22) return null;
+ const cfg = this.getHarmonicaConfig();
+ if (cfg.type !== 'diatonic') return null;
+ const HL = typeof window !== 'undefined' ? window.HarmonicaLayout : null;
+ if (!HL || typeof HL.playableSet !== 'function') return null;
+ return HL.playableSet(cfg, this.getInstrumentNoteRange());
+ }
+
+ /**
+ * Detect whether the selected instrument should switch to a special view.
+ * @returns {{ canFretboard: boolean, isDrum: boolean, instrumentType: string }}
+ */
+ getInstrumentViewInfo() {
+ // Delegates to the pure InstrumentDetector module (Phase B).
+ // Callers continue to receive the legacy { canFretboard, isBowed,
+ // isDrum, isWind, windPreset, instrumentType, instrumentSubtype,
+ // gmProgram } shape. `viewKind` is additionally exposed for the
+ // upcoming InstrumentView registry (Phase C+).
+ const detector = (typeof window !== 'undefined' && window.InstrumentDetector) || null;
+ if (!detector) {
+ // Defensive fallback — should never happen in production since
+ // InstrumentDetector.js is loaded before KeyboardModal.js.
+ return {
+ viewKind: 'piano',
+ canFretboard: false,
+ isBowed: false,
+ isDrum: false,
+ isWind: false,
+ windPreset: null,
+ instrumentType: 'unknown',
+ instrumentSubtype: '',
+ gmProgram: undefined
+ };
+ }
+ return detector.detect({
+ capabilities: this.selectedDeviceCapabilities,
+ selectedDevice: this.selectedDevice,
+ stringInstrumentConfig: this.stringInstrumentConfig,
+ windDb: typeof WindInstrumentDatabase !== 'undefined' ? WindInstrumentDatabase : null
+ });
+ }
+
+ /**
+ * Whether the currently selected instrument is a bowed-string family
+ * (violin, viola, cello, contrabass, tremolo, pizzicato, fiddle).
+ * Used by the fretboard renderer + chord bar to swap pluck/strum
+ * controls for the continuous-bow controls.
+ * @returns {boolean}
+ */
+ _isBowedInstrument() {
+ if (typeof this.getInstrumentViewInfo !== 'function') return false;
+ const info = this.getInstrumentViewInfo();
+ return info && info.isBowed === true;
+ }
+
+ /**
+ * Refresh the latency display in the header from the selected instrument.
+ */
+ updateLatencyDisplay() {
+ const el = document.getElementById('keyboard-latency-display');
+ if (!el) return;
+ const caps = this.selectedDeviceCapabilities;
+ const delay = caps && typeof caps.sync_delay === 'number' ? caps.sync_delay : null;
+ if (delay === null || !this.selectedDevice) {
+ el.classList.add('latency-empty');
+ el.textContent = '—';
+ } else {
+ el.classList.remove('latency-empty');
+ const sign = delay > 0 ? '+' : '';
+ el.textContent = `${sign}${delay} ms`;
+ }
+ }
+
+ /**
+ * GM-program → default string-instrument geometry (num_strings, num_frets,
+ * tuning in MIDI). Used as a fallback when the database has no per-channel
+ * string-instrument config for the selected device.
+ */
+ _getStringPresetForGmProgram(gmProgram) {
+ if (gmProgram === undefined || gmProgram === null) return null;
+ // Acoustic Guitar (nylon)
+ if (gmProgram === 24)
+ return {
+ num_strings: 6,
+ num_frets: 19,
+ tuning: [40, 45, 50, 55, 59, 64],
+ is_fretless: false
+ };
+ // Acoustic Guitar (steel)
+ if (gmProgram === 25)
+ return {
+ num_strings: 6,
+ num_frets: 20,
+ tuning: [40, 45, 50, 55, 59, 64],
+ is_fretless: false
+ };
+ // Electric guitars (jazz, clean, muted, overdriven, distortion, harmonics)
+ if (gmProgram >= 26 && gmProgram <= 31)
+ return {
+ num_strings: 6,
+ num_frets: 22,
+ tuning: [40, 45, 50, 55, 59, 64],
+ is_fretless: false
+ };
+ // Acoustic Bass
+ if (gmProgram === 32)
+ return { num_strings: 4, num_frets: 20, tuning: [28, 33, 38, 43], is_fretless: false };
+ // Electric basses (finger, pick, fretless, slap×2, synth×2)
+ if (gmProgram === 35)
+ return { num_strings: 4, num_frets: 0, tuning: [28, 33, 38, 43], is_fretless: true };
+ if (gmProgram >= 33 && gmProgram <= 39)
+ return { num_strings: 4, num_frets: 22, tuning: [28, 33, 38, 43], is_fretless: false };
+ // Orchestral strings (violin, viola, cello, contrabass, tremolo, pizzicato, ensemble1, ensemble2)
+ if (gmProgram === 40 || gmProgram === 110)
+ return { num_strings: 4, num_frets: 0, tuning: [55, 62, 69, 76], is_fretless: true }; // violin / fiddle
+ if (gmProgram === 41)
+ return { num_strings: 4, num_frets: 0, tuning: [48, 55, 62, 69], is_fretless: true }; // viola
+ if (gmProgram === 42)
+ return { num_strings: 4, num_frets: 0, tuning: [36, 43, 50, 57], is_fretless: true }; // cello
+ if (gmProgram === 43)
+ return { num_strings: 4, num_frets: 0, tuning: [28, 33, 38, 43], is_fretless: true }; // contrabass
+ if (gmProgram >= 44 && gmProgram <= 45)
+ return { num_strings: 4, num_frets: 0, tuning: [55, 62, 69, 76], is_fretless: true }; // tremolo / pizzicato (default to violin)
+ // Harp (special: many strings, no frets). 47-string concert harp:
+ // C-major diatonic from C1 (MIDI 24) up to G7 (MIDI 103). Matches
+ // the "Harpe" preset (StringInstrumentPresets.js → num_strings: 47).
+ if (gmProgram === 46) {
+ const STEP = [2, 2, 1, 2, 2, 2, 1]; // C-major intervals
+ const tuning = [];
+ let n = 24;
+ for (let i = 0; i < 47; i++) {
+ tuning.push(n);
+ n += STEP[i % 7];
+ }
+ return { num_strings: 47, num_frets: 0, tuning, is_fretless: true };
+ }
+ // Timpani — not a string instrument; fall through.
+ // Sitar
+ if (gmProgram === 104)
+ return {
+ num_strings: 7,
+ num_frets: 20,
+ tuning: [36, 43, 50, 55, 62, 69, 76],
+ is_fretless: false
+ };
+ // Banjo
+ if (gmProgram === 105)
+ return { num_strings: 5, num_frets: 22, tuning: [62, 67, 50, 55, 50], is_fretless: false };
+ // Shamisen (3 strings)
+ if (gmProgram === 106)
+ return { num_strings: 3, num_frets: 0, tuning: [50, 57, 62], is_fretless: true };
+ // Koto (13 strings, traditional tuning approximate)
+ if (gmProgram === 107)
+ return { num_strings: 13, num_frets: 0, tuning: null, is_fretless: true };
+ return null;
+ }
+
+ /**
+ * Try to load string-instrument config (num_strings, num_frets, tuning) for
+ * the selected instrument. Falls back to a GM-program preset when the
+ * database has no per-channel config.
+ */
+ async loadStringInstrumentConfig() {
+ this.stringInstrumentConfig = null;
+ if (!this.selectedDevice) return;
+ const deviceId = this.selectedDevice.device_id || this.selectedDevice.id;
+ const channel = this.getSelectedChannel();
+ try {
+ const resp = await this.backend.sendCommand('string_instrument_get', {
+ device_id: deviceId,
+ channel: channel
+ });
+ if (resp && resp.instrument) {
+ this.stringInstrumentConfig = resp.instrument;
this._mergeHandsConfigFromCapabilities();
if (typeof this._updateSlideModeGroupVisibility === 'function') {
- this._updateSlideModeGroupVisibility();
+ this._updateSlideModeGroupVisibility();
}
+ return;
+ }
+ } catch (e) {
+ /* ignore — fallback below */
}
- /**
- * Last-resort string set for the dedicated HarpView. A harp (GM 46) is
- * NOT a "string" instrument in the settings modal — it has no
- * strings/tuning tab; its playable notes come from the Notes section
- * (note range / discrete selection) which HarpView reads via
- * getInstrumentNoteRange(). This preset is only used when no range is
- * available at all. Kept off this.stringInstrumentConfig so the
- * InstrumentDetector escape hatch (a present stringCfg forces the
- * fretboard view for GM 46) stays untouched.
- * @returns {{tuning:number[], num_strings:number}|null}
- */
- _harpPresetStringConfig() {
- const caps = this.selectedDeviceCapabilities;
- const gmProgram = (caps && caps.gm_program) ?? (this.selectedDevice && this.selectedDevice.gm_program);
- const preset = this._getStringPresetForGmProgram(gmProgram);
- if (preset && Array.isArray(preset.tuning) && preset.tuning.length) {
- return { tuning: preset.tuning, num_strings: preset.num_strings };
- }
- return null;
- }
-
- /**
- * Merge hands_config from selectedDeviceCapabilities into stringInstrumentConfig.
- * This is needed because hands_config is saved via the instrument settings modal
- * (instrument_save_all) but stringInstrumentConfig is loaded from string_instrument_get.
- */
- _mergeHandsConfigFromCapabilities() {
- if (!this.stringInstrumentConfig) return;
- const caps = this.selectedDeviceCapabilities;
- if (caps && caps.hands_config) {
- this.stringInstrumentConfig.hands_config = caps.hands_config;
- }
+ // Fallback: GM-program-based defaults bundled in the frontend.
+ const caps = this.selectedDeviceCapabilities;
+ const gmProgram =
+ (caps && caps.gm_program) ?? (this.selectedDevice && this.selectedDevice.gm_program);
+ const preset = this._getStringPresetForGmProgram(gmProgram);
+ if (preset) {
+ this.stringInstrumentConfig = preset;
}
-
- populateDeviceSelect() {
- this._buildInstrumentDropdown();
- this._updateInstrumentTrigger();
+ this._mergeHandsConfigFromCapabilities();
+ if (typeof this._updateSlideModeGroupVisibility === 'function') {
+ this._updateSlideModeGroupVisibility();
}
-
- _buildInstrumentDropdown() {
- const dropdown = document.getElementById('instrument-dropdown');
- if (!dropdown) return;
-
- // Build off-DOM with a DocumentFragment so we attach ~138 nodes
- // in one paint, then swap children atomically.
- const frag = document.createDocumentFragment();
-
- // "No selection" entry — spans full grid width
- const noneBtn = document.createElement('button');
- noneBtn.type = 'button';
- noneBtn.className = 'instrument-option option-none' + (!this.selectedDevice ? ' selected' : '');
- noneBtn.dataset.deviceId = '';
- noneBtn.innerHTML = `
+ }
+
+ /**
+ * Last-resort string set for the dedicated HarpView. A harp (GM 46) is
+ * NOT a "string" instrument in the settings modal — it has no
+ * strings/tuning tab; its playable notes come from the Notes section
+ * (note range / discrete selection) which HarpView reads via
+ * getInstrumentNoteRange(). This preset is only used when no range is
+ * available at all. Kept off this.stringInstrumentConfig so the
+ * InstrumentDetector escape hatch (a present stringCfg forces the
+ * fretboard view for GM 46) stays untouched.
+ * @returns {{tuning:number[], num_strings:number}|null}
+ */
+ _harpPresetStringConfig() {
+ const caps = this.selectedDeviceCapabilities;
+ const gmProgram =
+ (caps && caps.gm_program) ?? (this.selectedDevice && this.selectedDevice.gm_program);
+ const preset = this._getStringPresetForGmProgram(gmProgram);
+ if (preset && Array.isArray(preset.tuning) && preset.tuning.length) {
+ return { tuning: preset.tuning, num_strings: preset.num_strings };
+ }
+ return null;
+ }
+
+ /**
+ * Merge hands_config from selectedDeviceCapabilities into stringInstrumentConfig.
+ * This is needed because hands_config is saved via the instrument settings modal
+ * (instrument_save_all) but stringInstrumentConfig is loaded from string_instrument_get.
+ */
+ _mergeHandsConfigFromCapabilities() {
+ if (!this.stringInstrumentConfig) return;
+ const caps = this.selectedDeviceCapabilities;
+ if (caps && caps.hands_config) {
+ this.stringInstrumentConfig.hands_config = caps.hands_config;
+ }
+ }
+
+ populateDeviceSelect() {
+ this._buildInstrumentDropdown();
+ this._updateInstrumentTrigger();
+ }
+
+ _buildInstrumentDropdown() {
+ const dropdown = document.getElementById('instrument-dropdown');
+ if (!dropdown) return;
+
+ // Build off-DOM with a DocumentFragment so we attach ~138 nodes
+ // in one paint, then swap children atomically.
+ const frag = document.createDocumentFragment();
+
+ // "No selection" entry — spans full grid width
+ const noneBtn = document.createElement('button');
+ noneBtn.type = 'button';
+ noneBtn.className = 'instrument-option option-none' + (!this.selectedDevice ? ' selected' : '');
+ noneBtn.dataset.deviceId = '';
+ noneBtn.innerHTML = `
🎵
— ${this.t('common.select')} —
`;
- frag.appendChild(noneBtn);
-
- this.devices.forEach(device => {
- const deviceId = device.device_id || device.id;
- const rawValue = device._multiInstrument ? `${deviceId}::${device.channel}` : deviceId;
- const gmProgram = device.gm_program;
- const channel = device.channel;
-
- const icon = window.InstrumentFamilies
- ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram, channel })
- : { svgUrl: null, emoji: '🎵' };
-
- const isSelected = this.selectedDevice
- && (this.selectedDevice.device_id === deviceId || this.selectedDevice.id === deviceId)
- && (!device._multiInstrument || device.channel === this.selectedDevice.channel);
-
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'instrument-option' + (isSelected ? ' selected' : '');
- btn.dataset.deviceId = rawValue;
-
- const name = device.displayName || device.name;
- const chLabel = device._multiInstrument
- ? `Ch${(channel || 0) + 1} `
- : '';
-
- const imgHtml = icon.svgUrl
- ? `${icon.emoji} `
- : `${icon.emoji} `;
-
- // `name` is a user-configurable device label → never interpolate
- // it into innerHTML. The icon markup and chLabel are built from
- // internal/numeric data only, so they stay as markup.
- btn.innerHTML = `${imgHtml}
`;
- const nameSpan = btn.querySelector('.option-name');
- nameSpan.textContent = name;
- if (chLabel) nameSpan.insertAdjacentHTML('beforeend', chLabel);
- frag.appendChild(btn);
- });
-
- dropdown.replaceChildren(frag);
-
- // Event delegation: a single listener on the container, instead of
- // one per button. Guard on the element itself (not an instance flag)
- // so each freshly created #instrument-dropdown gets its own listener;
- // a stale instance flag would skip rebinding after close()/mountAsPanel().
- if (!dropdown._instrumentClickBound) {
- dropdown.addEventListener('click', (e) => {
- const opt = e.target.closest('.instrument-option');
- if (!opt || !dropdown.contains(opt)) return;
- const rawValue = opt.dataset.deviceId ?? '';
- this._selectInstrumentOption(rawValue);
- });
- dropdown._instrumentClickBound = true;
- }
+ frag.appendChild(noneBtn);
+
+ this.devices.forEach((device) => {
+ const deviceId = device.device_id || device.id;
+ const rawValue = device._multiInstrument ? `${deviceId}::${device.channel}` : deviceId;
+ const gmProgram = device.gm_program;
+ const channel = device.channel;
+
+ const icon = window.InstrumentFamilies
+ ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram, channel })
+ : { svgUrl: null, emoji: '🎵' };
+
+ const isSelected =
+ this.selectedDevice &&
+ (this.selectedDevice.device_id === deviceId || this.selectedDevice.id === deviceId) &&
+ (!device._multiInstrument || device.channel === this.selectedDevice.channel);
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'instrument-option' + (isSelected ? ' selected' : '');
+ btn.dataset.deviceId = rawValue;
+
+ const name = device.displayName || device.name;
+ const chLabel = device._multiInstrument
+ ? `Ch${(channel || 0) + 1} `
+ : '';
+
+ const imgHtml = icon.svgUrl
+ ? `${icon.emoji} `
+ : `${icon.emoji} `;
+
+ // `name` is a user-configurable device label → never interpolate
+ // it into innerHTML. The icon markup and chLabel are built from
+ // internal/numeric data only, so they stay as markup.
+ btn.innerHTML = `${imgHtml}
`;
+ const nameSpan = btn.querySelector('.option-name');
+ nameSpan.textContent = name;
+ if (chLabel) nameSpan.insertAdjacentHTML('beforeend', chLabel);
+ frag.appendChild(btn);
+ });
+
+ dropdown.replaceChildren(frag);
+
+ // Event delegation: a single listener on the container, instead of
+ // one per button. Guard on the element itself (not an instance flag)
+ // so each freshly created #instrument-dropdown gets its own listener;
+ // a stale instance flag would skip rebinding after close()/mountAsPanel().
+ if (!dropdown._instrumentClickBound) {
+ dropdown.addEventListener('click', (e) => {
+ const opt = e.target.closest('.instrument-option');
+ if (!opt || !dropdown.contains(opt)) return;
+ const rawValue = opt.dataset.deviceId ?? '';
+ this._selectInstrumentOption(rawValue);
+ });
+ dropdown._instrumentClickBound = true;
}
-
- // Adapt the instrument dropdown to the number of instruments: widen it
- // (more columns, up to the screen edge) then grow its height to the
- // viewport so everything shows with as little scrolling as possible.
- // CSS `auto-fill` can't pick a column count proportional to a dynamic
- // item count without a definite width, so we compute it here on open
- // and clear the inline sizing on close (the dropdown is reused across
- // the standalone modal and the loop-editor panel mount).
- _sizeInstrumentDropdown(isOpen) {
- const dropdown = document.getElementById('instrument-dropdown');
- if (!dropdown) return;
-
- if (!isOpen) {
- dropdown.style.width = '';
- dropdown.style.maxHeight = '';
- dropdown.style.gridTemplateColumns = '';
- return;
- }
-
- const trigger = document.getElementById('instrument-trigger');
- const cell = dropdown.querySelector('.instrument-option:not(.option-none)');
- if (!trigger || !cell) return;
-
- const cs = getComputedStyle(dropdown);
- const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
- const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
- const gap = parseFloat(cs.rowGap || cs.gap) || 2;
-
- const itemW = cell.offsetWidth || 78;
- const rowH = cell.offsetHeight || 64;
-
- const noneEl = dropdown.querySelector('.option-none');
- let noneH = 0;
- if (noneEl) {
- const nb = parseFloat(getComputedStyle(noneEl).marginBottom) || 0;
- noneH = noneEl.offsetHeight + nb + gap;
- }
-
- const nData = dropdown.querySelectorAll('.instrument-option:not(.option-none)').length;
- if (nData === 0) {
- dropdown.style.width = '';
- dropdown.style.maxHeight = '';
- dropdown.style.gridTemplateColumns = '';
- return;
- }
-
- const rect = trigger.getBoundingClientRect();
- const panelMode = !!dropdown.closest('.modal-dialog.km-panel-mode');
-
- let availW = Math.min(window.innerWidth - 12, window.innerWidth - rect.left - 8);
- availW = Math.max(320, availW);
- const availH = Math.max(
- 160,
- panelMode ? rect.top - 12 : window.innerHeight - rect.bottom - 12
- );
-
- const colsForW = (w) => Math.max(1, Math.floor((w - padX + gap) / (itemW + gap)));
- // Compact baseline = the column count that fills the 320px min-width
- // (≈ today's look); never go below it so the few-instrument case is
- // unchanged. Cap = how many columns fit out to the screen edge.
- const minCols = colsForW(320);
- const maxCols = Math.max(minCols, colsForW(availW));
-
- const heightFor = (cols) => {
- const rows = Math.ceil(nData / cols);
- return padY + noneH + rows * rowH + (rows - 1) * gap;
- };
-
- // Stay compact when everything already fits; otherwise widen just
- // enough to fit the viewport height. If even the widest grid can't
- // fit, use the widest one (scroll minimized "au max").
- let cols = maxCols;
- for (let c = minCols; c <= maxCols; c++) {
- if (heightFor(c) <= availH) { cols = c; break; }
- }
-
- const width = Math.min(
- availW,
- Math.max(320, cols * itemW + (cols - 1) * gap + padX)
- );
-
- dropdown.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
- dropdown.style.width = `${Math.round(width)}px`;
- dropdown.style.maxHeight = `${Math.round(availH)}px`;
+ }
+
+ // Adapt the instrument dropdown to the number of instruments: widen it
+ // (more columns, up to the screen edge) then grow its height to the
+ // viewport so everything shows with as little scrolling as possible.
+ // CSS `auto-fill` can't pick a column count proportional to a dynamic
+ // item count without a definite width, so we compute it here on open
+ // and clear the inline sizing on close (the dropdown is reused across
+ // the standalone modal and the loop-editor panel mount).
+ _sizeInstrumentDropdown(isOpen) {
+ const dropdown = document.getElementById('instrument-dropdown');
+ if (!dropdown) return;
+
+ if (!isOpen) {
+ dropdown.style.width = '';
+ dropdown.style.maxHeight = '';
+ dropdown.style.gridTemplateColumns = '';
+ return;
}
- _updateInstrumentTrigger() {
- const triggerSvg = document.getElementById('instrument-trigger-svg');
- const triggerEmoji = document.getElementById('instrument-trigger-emoji');
- const triggerName = document.getElementById('instrument-trigger-name');
- if (!triggerName) return;
+ const trigger = document.getElementById('instrument-trigger');
+ const cell = dropdown.querySelector('.instrument-option:not(.option-none)');
+ if (!trigger || !cell) return;
- if (!this.selectedDevice) {
- if (triggerSvg) triggerSvg.style.display = 'none';
- if (triggerEmoji) { triggerEmoji.textContent = '🎵'; triggerEmoji.style.display = 'inline'; }
- triggerName.textContent = `— ${this.t('common.select')} —`;
- return;
- }
+ const cs = getComputedStyle(dropdown);
+ const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
+ const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
+ const gap = parseFloat(cs.rowGap || cs.gap) || 2;
- const gmProgram = this.selectedDevice.gm_program;
- const channel = this.selectedDevice.channel;
- const icon = window.InstrumentFamilies
- ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram, channel })
- : { svgUrl: null, emoji: '🎵' };
-
- if (icon.svgUrl && triggerSvg) {
- triggerSvg.src = icon.svgUrl;
- triggerSvg.style.display = 'block';
- triggerSvg.onerror = () => {
- triggerSvg.style.display = 'none';
- if (triggerEmoji) { triggerEmoji.textContent = icon.emoji; triggerEmoji.style.display = 'inline'; }
- };
- if (triggerEmoji) triggerEmoji.style.display = 'none';
- } else {
- if (triggerSvg) triggerSvg.style.display = 'none';
- if (triggerEmoji) { triggerEmoji.textContent = icon.emoji; triggerEmoji.style.display = 'inline'; }
- }
+ const itemW = cell.offsetWidth || 78;
+ const rowH = cell.offsetHeight || 64;
- triggerName.textContent = this.selectedDevice.displayName || this.selectedDevice.name;
+ const noneEl = dropdown.querySelector('.option-none');
+ let noneH = 0;
+ if (noneEl) {
+ const nb = parseFloat(getComputedStyle(noneEl).marginBottom) || 0;
+ noneH = noneEl.offsetHeight + nb + gap;
}
- async _selectInstrumentOption(rawValue) {
- // Close dropdown
- const dropdown = document.getElementById('instrument-dropdown');
- const selector = document.getElementById('header-instrument-selector');
- const trigger = document.getElementById('instrument-trigger');
- dropdown?.classList.remove('open');
- selector?.classList.remove('open');
- if (trigger) trigger.setAttribute('aria-expanded', 'false');
- this._sizeInstrumentDropdown(false);
-
- // Stop any held bow before swapping instruments — the new chord bar
- // about to be rendered won't reuse the previous button reference, and
- // the chord notes would otherwise keep ringing until the next mouseup.
- if (typeof this._stopActiveBow === 'function') this._stopActiveBow();
-
- let deviceId = rawValue;
- let selectedChannel = undefined;
-
- if (rawValue.includes('::')) {
- const parts = rawValue.split('::');
- deviceId = parts[0];
- selectedChannel = parseInt(parts[1]);
- }
-
- this.selectedDevice = rawValue
- ? this.devices.find(d => {
- if (d._multiInstrument && selectedChannel !== undefined) {
- return (d.device_id === deviceId || d.id === deviceId) && d.channel === selectedChannel;
- }
- return d.device_id === deviceId || d.id === deviceId;
- }) || null
- : null;
+ const nData = dropdown.querySelectorAll('.instrument-option:not(.option-none)').length;
+ if (nData === 0) {
+ dropdown.style.width = '';
+ dropdown.style.maxHeight = '';
+ dropdown.style.gridTemplateColumns = '';
+ return;
+ }
- this.stringInstrumentConfig = null;
-
- await this.loadDeviceCapabilities(deviceId || null, selectedChannel);
- this.autoCenterKeyboard();
- this.updateSlidersVisibility();
-
- this.modulation = 64;
- this._updateModWheelPosition(64);
- const modDisplay = document.getElementById('keyboard-modulation-display');
- if (modDisplay) modDisplay.textContent = '64';
-
- this.updateLatencyDisplay();
- this._updateInstrumentTrigger();
- this._buildInstrumentDropdown(); // refresh selected state
-
- const info = this.getInstrumentViewInfo();
- const viewGroup = document.getElementById('keyboard-view-mode-group');
- // keyboard_type:'chromatic' instruments (xylophone, marimba, hangdrum …) use
- // the equal-width list view as their default so fingers align with uniform slots.
- const caps = this.selectedDeviceCapabilities;
- const handsKbType = caps && caps.hands_config && caps.hands_config.keyboard_type;
- const isChromatic = handsKbType === 'chromatic' || caps && caps.keyboard_type === 'chromatic';
-
- // Always reset wind controls before re-evaluating the new instrument
- if (typeof this._hideWindControls === 'function') this._hideWindControls();
-
- if (info.isDrum) {
- if (viewGroup) viewGroup.classList.remove('hidden');
- this.stringInstrumentConfig = null;
- this._harpStringConfig = null;
- this.setViewMode('drumpad');
- } else if (info.canFretboard) {
- await this.loadStringInstrumentConfig();
- this._harpStringConfig = null;
- if (viewGroup) viewGroup.classList.remove('hidden');
- this.setViewMode('fretboard');
- } else if (info.isWind) {
- this.stringInstrumentConfig = null;
- this._harpStringConfig = null;
- // Keep the view-mode toggle visible so the user can switch a
- // wind instrument to the standard piano (and back).
- if (viewGroup) viewGroup.classList.remove('hidden');
- // Show wind articulation panel and switch to piano-slider (chromatic keys)
- if (typeof this._showWindControls === 'function') {
- this._showWindControls(info.windPreset);
- }
- this.setViewMode('piano-slider');
- // Comfort zone is applied inside generatePianoSlider via _applyWindComfortZone
- } else if (isChromatic) {
- this.stringInstrumentConfig = null;
- this._harpStringConfig = null;
- if (viewGroup) viewGroup.classList.add('hidden');
- this.setViewMode('keyboard-list');
- } else if (info.viewKind === 'harp') {
- // Dedicated vertical-string HarpView. stringInstrumentConfig is
- // kept null so the InstrumentDetector escape hatch doesn't flip
- // a re-selected harp to the fretboard. HarpView derives its
- // strings from the configured note selection via
- // getInstrumentNoteRange(); _harpStringConfig is only the
- // last-resort 47-string preset when no range is available.
- this.stringInstrumentConfig = null;
- this._harpStringConfig = this._harpPresetStringConfig();
- if (viewGroup) viewGroup.classList.remove('hidden');
- this.setViewMode('harp');
- } else if (['harmonica', 'accordion', 'mallet', 'music-box',
- 'kalimba', 'bagpipe', 'steel-drum', 'perc-pad',
- 'theremin']
- .includes(info.viewKind)) {
- // Dedicated self-owned views. The view owns its DOM host; no
- // built-in container or string config involved. The view-mode
- // toggle group stays VISIBLE so the standard virtual piano is
- // always reachable for any specific instrument (the toggle
- // handler swaps this.viewMode ↔ 'piano' symmetrically).
- this.stringInstrumentConfig = null;
- this._harpStringConfig = null;
- if (viewGroup) viewGroup.classList.remove('hidden');
- this.setViewMode(info.viewKind);
- } else {
- this.stringInstrumentConfig = null;
- this._harpStringConfig = null;
- if (viewGroup) viewGroup.classList.add('hidden');
- this.setViewMode('piano');
- }
+ const rect = trigger.getBoundingClientRect();
+ const panelMode = !!dropdown.closest('.modal-dialog.km-panel-mode');
+
+ let availW = Math.min(window.innerWidth - 12, window.innerWidth - rect.left - 8);
+ availW = Math.max(320, availW);
+ const availH = Math.max(160, panelMode ? rect.top - 12 : window.innerHeight - rect.bottom - 12);
+
+ const colsForW = (w) => Math.max(1, Math.floor((w - padX + gap) / (itemW + gap)));
+ // Compact baseline = the column count that fills the 320px min-width
+ // (≈ today's look); never go below it so the few-instrument case is
+ // unchanged. Cap = how many columns fit out to the screen edge.
+ const minCols = colsForW(320);
+ const maxCols = Math.max(minCols, colsForW(availW));
+
+ const heightFor = (cols) => {
+ const rows = Math.ceil(nData / cols);
+ return padY + noneH + rows * rowH + (rows - 1) * gap;
+ };
+
+ // Stay compact when everything already fits; otherwise widen just
+ // enough to fit the viewport height. If even the widest grid can't
+ // fit, use the widest one (scroll minimized "au max").
+ let cols = maxCols;
+ for (let c = minCols; c <= maxCols; c++) {
+ if (heightFor(c) <= availH) {
+ cols = c;
+ break;
+ }
+ }
- this.regeneratePianoKeys();
-
- if (this._panelCallbacks?.onInstrumentSelected) {
- // Pass the detected instrument type alongside the raw routing
- // info so hosts (LoopEditor) don't have to re-derive isDrum /
- // isWind from `gmProgram` + `channel` — those two alone miss
- // drum kits whose device exposes a melodic gm_program on a
- // non-9 channel. `info` comes from `getInstrumentViewInfo()`
- // which already folds `caps.instrument_type === 'drum'` into
- // the flag. `viewMode === 'drumpad'` is the authoritative
- // tiebreaker — if we activated drum-pad UI just above, the
- // user is clearly playing drums even if caps doesn't agree.
- const finalIsDrum = info.isDrum === true || this.viewMode === 'drumpad';
- this._panelCallbacks.onInstrumentSelected({
- deviceId: this.selectedDevice?.device_id || this.selectedDevice?.id || null,
- channel: this.getSelectedChannel(),
- gmProgram: this.selectedDeviceCapabilities?.gm_program ?? 0,
- instrumentType: info.instrumentType || this.selectedDeviceCapabilities?.instrument_type || null,
- isDrum: finalIsDrum,
- isWind: info.isWind === true,
- viewMode: this.viewMode
- });
- }
+ const width = Math.min(availW, Math.max(320, cols * itemW + (cols - 1) * gap + padX));
+
+ dropdown.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
+ dropdown.style.width = `${Math.round(width)}px`;
+ dropdown.style.maxHeight = `${Math.round(availH)}px`;
+ }
+
+ _updateInstrumentTrigger() {
+ const triggerSvg = document.getElementById('instrument-trigger-svg');
+ const triggerEmoji = document.getElementById('instrument-trigger-emoji');
+ const triggerName = document.getElementById('instrument-trigger-name');
+ if (!triggerName) return;
+
+ if (!this.selectedDevice) {
+ if (triggerSvg) triggerSvg.style.display = 'none';
+ if (triggerEmoji) {
+ triggerEmoji.textContent = '🎵';
+ triggerEmoji.style.display = 'inline';
+ }
+ triggerName.textContent = `— ${this.t('common.select')} —`;
+ return;
}
- /**
- * Refresh the device list if the modal is open
- */
- async refreshDevices() {
- if (!this.isOpen) return;
+ const gmProgram = this.selectedDevice.gm_program;
+ const channel = this.selectedDevice.channel;
+ const icon = window.InstrumentFamilies
+ ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram, channel })
+ : { svgUrl: null, emoji: '🎵' };
+
+ if (icon.svgUrl && triggerSvg) {
+ triggerSvg.src = icon.svgUrl;
+ triggerSvg.style.display = 'block';
+ triggerSvg.onerror = () => {
+ triggerSvg.style.display = 'none';
+ if (triggerEmoji) {
+ triggerEmoji.textContent = icon.emoji;
+ triggerEmoji.style.display = 'inline';
+ }
+ };
+ if (triggerEmoji) triggerEmoji.style.display = 'none';
+ } else {
+ if (triggerSvg) triggerSvg.style.display = 'none';
+ if (triggerEmoji) {
+ triggerEmoji.textContent = icon.emoji;
+ triggerEmoji.style.display = 'inline';
+ }
+ }
- this.logger.info('[KeyboardModal] Refreshing devices...');
- await this.loadDevices();
- this.populateDeviceSelect();
+ triggerName.textContent = this.selectedDevice.displayName || this.selectedDevice.name;
+ }
+
+ async _selectInstrumentOption(rawValue) {
+ // Close dropdown
+ const dropdown = document.getElementById('instrument-dropdown');
+ const selector = document.getElementById('header-instrument-selector');
+ const trigger = document.getElementById('instrument-trigger');
+ dropdown?.classList.remove('open');
+ selector?.classList.remove('open');
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
+ this._sizeInstrumentDropdown(false);
+
+ // Stop any held bow before swapping instruments — the new chord bar
+ // about to be rendered won't reuse the previous button reference, and
+ // the chord notes would otherwise keep ringing until the next mouseup.
+ if (typeof this._stopActiveBow === 'function') this._stopActiveBow();
+
+ let deviceId = rawValue;
+ let selectedChannel = undefined;
+
+ if (rawValue.includes('::')) {
+ const parts = rawValue.split('::');
+ deviceId = parts[0];
+ selectedChannel = parseInt(parts[1]);
}
- _subscribeLocale() {
- if (typeof i18n !== 'undefined') {
- this.localeUnsubscribe = i18n.onLocaleChange(() => {
- this.updateTranslations();
- this.populateDeviceSelect();
- });
- }
+ this.selectedDevice = rawValue
+ ? this.devices.find((d) => {
+ if (d._multiInstrument && selectedChannel !== undefined) {
+ return (d.device_id === deviceId || d.id === deviceId) && d.channel === selectedChannel;
+ }
+ return d.device_id === deviceId || d.id === deviceId;
+ }) || null
+ : null;
+
+ this.stringInstrumentConfig = null;
+
+ await this.loadDeviceCapabilities(deviceId || null, selectedChannel);
+ this.autoCenterKeyboard();
+ this.updateSlidersVisibility();
+
+ this.modulation = 64;
+ this._updateModWheelPosition(64);
+ const modDisplay = document.getElementById('keyboard-modulation-display');
+ if (modDisplay) modDisplay.textContent = '64';
+
+ this.updateLatencyDisplay();
+ this._updateInstrumentTrigger();
+ this._buildInstrumentDropdown(); // refresh selected state
+
+ const info = this.getInstrumentViewInfo();
+ const viewGroup = document.getElementById('keyboard-view-mode-group');
+ // keyboard_type:'chromatic' instruments (xylophone, marimba, hangdrum …) use
+ // the equal-width list view as their default so fingers align with uniform slots.
+ const caps = this.selectedDeviceCapabilities;
+ const handsKbType = caps && caps.hands_config && caps.hands_config.keyboard_type;
+ const isChromatic = handsKbType === 'chromatic' || (caps && caps.keyboard_type === 'chromatic');
+
+ // Always reset wind controls before re-evaluating the new instrument
+ if (typeof this._hideWindControls === 'function') this._hideWindControls();
+
+ if (info.isDrum) {
+ if (viewGroup) viewGroup.classList.remove('hidden');
+ this.stringInstrumentConfig = null;
+ this._harpStringConfig = null;
+ this.setViewMode('drumpad');
+ } else if (info.canFretboard) {
+ await this.loadStringInstrumentConfig();
+ this._harpStringConfig = null;
+ if (viewGroup) viewGroup.classList.remove('hidden');
+ this.setViewMode('fretboard');
+ } else if (info.isWind) {
+ this.stringInstrumentConfig = null;
+ this._harpStringConfig = null;
+ // Keep the view-mode toggle visible so the user can switch a
+ // wind instrument to the standard piano (and back).
+ if (viewGroup) viewGroup.classList.remove('hidden');
+ // Show wind articulation panel and switch to piano-slider (chromatic keys)
+ if (typeof this._showWindControls === 'function') {
+ this._showWindControls(info.windPreset);
+ }
+ this.setViewMode('piano-slider');
+ // Comfort zone is applied inside generatePianoSlider via _applyWindComfortZone
+ } else if (isChromatic) {
+ this.stringInstrumentConfig = null;
+ this._harpStringConfig = null;
+ if (viewGroup) viewGroup.classList.add('hidden');
+ this.setViewMode('keyboard-list');
+ } else if (info.viewKind === 'harp') {
+ // Dedicated vertical-string HarpView. stringInstrumentConfig is
+ // kept null so the InstrumentDetector escape hatch doesn't flip
+ // a re-selected harp to the fretboard. HarpView derives its
+ // strings from the configured note selection via
+ // getInstrumentNoteRange(); _harpStringConfig is only the
+ // last-resort 47-string preset when no range is available.
+ this.stringInstrumentConfig = null;
+ this._harpStringConfig = this._harpPresetStringConfig();
+ if (viewGroup) viewGroup.classList.remove('hidden');
+ this.setViewMode('harp');
+ } else if (
+ [
+ 'harmonica',
+ 'accordion',
+ 'mallet',
+ 'music-box',
+ 'kalimba',
+ 'bagpipe',
+ 'steel-drum',
+ 'perc-pad',
+ 'theremin'
+ ].includes(info.viewKind)
+ ) {
+ // Dedicated self-owned views. The view owns its DOM host; no
+ // built-in container or string config involved. The view-mode
+ // toggle group stays VISIBLE so the standard virtual piano is
+ // always reachable for any specific instrument (the toggle
+ // handler swaps this.viewMode ↔ 'piano' symmetrically).
+ this.stringInstrumentConfig = null;
+ this._harpStringConfig = null;
+ if (viewGroup) viewGroup.classList.remove('hidden');
+ this.setViewMode(info.viewKind);
+ } else {
+ this.stringInstrumentConfig = null;
+ this._harpStringConfig = null;
+ if (viewGroup) viewGroup.classList.add('hidden');
+ this.setViewMode('piano');
}
- mountAsPanel(panelContainer, callbacks = {}) {
- if (this._panelMode) return;
- if (this.isOpen) this.close();
+ this.regeneratePianoKeys();
+
+ if (this._panelCallbacks?.onInstrumentSelected) {
+ // Pass the detected instrument type alongside the raw routing
+ // info so hosts (LoopEditor) don't have to re-derive isDrum /
+ // isWind from `gmProgram` + `channel` — those two alone miss
+ // drum kits whose device exposes a melodic gm_program on a
+ // non-9 channel. `info` comes from `getInstrumentViewInfo()`
+ // which already folds `caps.instrument_type === 'drum'` into
+ // the flag. `viewMode === 'drumpad'` is the authoritative
+ // tiebreaker — if we activated drum-pad UI just above, the
+ // user is clearly playing drums even if caps doesn't agree.
+ const finalIsDrum = info.isDrum === true || this.viewMode === 'drumpad';
+ this._panelCallbacks.onInstrumentSelected({
+ deviceId: this.selectedDevice?.device_id || this.selectedDevice?.id || null,
+ channel: this.getSelectedChannel(),
+ gmProgram: this.selectedDeviceCapabilities?.gm_program ?? 0,
+ instrumentType:
+ info.instrumentType || this.selectedDeviceCapabilities?.instrument_type || null,
+ isDrum: finalIsDrum,
+ isWind: info.isWind === true,
+ viewMode: this.viewMode
+ });
+ }
+ }
+
+ /**
+ * Refresh the device list if the modal is open
+ */
+ async refreshDevices() {
+ if (!this.isOpen) return;
+
+ this.logger.info('[KeyboardModal] Refreshing devices...');
+ await this.loadDevices();
+ this.populateDeviceSelect();
+ }
+
+ _subscribeLocale() {
+ if (typeof i18n !== 'undefined') {
+ this.localeUnsubscribe = i18n.onLocaleChange(() => {
+ this.updateTranslations();
+ this.populateDeviceSelect();
+ });
+ }
+ }
- this._panelCallbacks = callbacks;
- this._panelMode = true;
+ mountAsPanel(panelContainer, callbacks = {}) {
+ if (this._panelMode) return;
+ if (this.isOpen) this.close();
- this.loadSettings();
- this.createModal();
- this.isOpen = true;
- this.container.classList.add('km-panel-host'); // hide overlay shell — display:flex!important in CSS would override style.display
+ this._panelCallbacks = callbacks;
+ this._panelMode = true;
- const dialogEl = this.container.querySelector('.modal-dialog');
- if (dialogEl) {
- dialogEl.classList.add('km-panel-mode');
- panelContainer.appendChild(dialogEl);
- this._panelDialogEl = dialogEl;
- }
+ this.loadSettings();
+ this.createModal();
+ this.isOpen = true;
+ this.container.classList.add('km-panel-host'); // hide overlay shell — display:flex!important in CSS would override style.display
- this.loadDevices().then(() => this.populateDeviceSelect());
- this.attachEvents();
- this.updateSlidersVisibility();
- this._subscribeLocale();
+ const dialogEl = this.container.querySelector('.modal-dialog');
+ if (dialogEl) {
+ dialogEl.classList.add('km-panel-mode');
+ panelContainer.appendChild(dialogEl);
+ this._panelDialogEl = dialogEl;
}
- unmountPanel() {
- if (!this._panelMode) return;
+ this.loadDevices().then(() => this.populateDeviceSelect());
+ this.attachEvents();
+ this.updateSlidersVisibility();
+ this._subscribeLocale();
+ }
- if (this._panelDialogEl && this.container) {
- this._panelDialogEl.classList.remove('km-panel-mode');
- this.container.classList.remove('km-panel-host');
- this.container.appendChild(this._panelDialogEl);
- this._panelDialogEl = null;
- }
+ unmountPanel() {
+ if (!this._panelMode) return;
- this._panelMode = false;
- this._panelCallbacks = null;
- this.close(); // detachEvents, stop notes, removes this.container
+ if (this._panelDialogEl && this.container) {
+ this._panelDialogEl.classList.remove('km-panel-mode');
+ this.container.classList.remove('km-panel-host');
+ this.container.appendChild(this._panelDialogEl);
+ this._panelDialogEl = null;
}
+
+ this._panelMode = false;
+ this._panelCallbacks = null;
+ this.close(); // detachEvents, stop notes, removes this.container
+ }
}
// ============================================================================
@@ -1641,31 +1797,31 @@ class KeyboardModal {
* @param {Object} mixin Object whose own properties become prototype methods.
*/
function _applyMixin(label, mixin) {
- if (!mixin) return;
- for (const key of Object.keys(mixin)) {
- if (Object.prototype.hasOwnProperty.call(KeyboardModal.prototype, key)) {
- // eslint-disable-next-line no-console
- console.warn(
- `[KeyboardModal] mixin "${label}" overrides existing method "${key}". ` +
- `Expected only for KeyboardWind._updatePianoSliderGroupVisibility ` +
- `(intentional, documented in KeyboardWind.js).`
- );
- }
- KeyboardModal.prototype[key] = mixin[key];
+ if (!mixin) return;
+ for (const key of Object.keys(mixin)) {
+ if (Object.prototype.hasOwnProperty.call(KeyboardModal.prototype, key)) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[KeyboardModal] mixin "${label}" overrides existing method "${key}". ` +
+ `Expected only for KeyboardWind._updatePianoSliderGroupVisibility ` +
+ `(intentional, documented in KeyboardWind.js).`
+ );
}
+ KeyboardModal.prototype[key] = mixin[key];
+ }
}
-if (typeof KeyboardPianoMixin !== 'undefined') _applyMixin('Piano', KeyboardPianoMixin);
-if (typeof KeyboardEventsMixin !== 'undefined') _applyMixin('Events', KeyboardEventsMixin);
-if (typeof KeyboardControlsMixin !== 'undefined') _applyMixin('Controls', KeyboardControlsMixin);
-if (typeof KeyboardChordsMixin !== 'undefined') _applyMixin('Chords', KeyboardChordsMixin);
-if (typeof KeyboardSliderMixin !== 'undefined') _applyMixin('Slider', KeyboardSliderMixin);
-if (typeof KeyboardListViewMixin !== 'undefined') _applyMixin('ListView', KeyboardListViewMixin);
+if (typeof KeyboardPianoMixin !== 'undefined') _applyMixin('Piano', KeyboardPianoMixin);
+if (typeof KeyboardEventsMixin !== 'undefined') _applyMixin('Events', KeyboardEventsMixin);
+if (typeof KeyboardControlsMixin !== 'undefined') _applyMixin('Controls', KeyboardControlsMixin);
+if (typeof KeyboardChordsMixin !== 'undefined') _applyMixin('Chords', KeyboardChordsMixin);
+if (typeof KeyboardSliderMixin !== 'undefined') _applyMixin('Slider', KeyboardSliderMixin);
+if (typeof KeyboardListViewMixin !== 'undefined') _applyMixin('ListView', KeyboardListViewMixin);
// KM-C4 done: KeyboardWindMixin no longer overrides playNote. Wind
// articulation + staccato now live in PianoSliderView via the
// willPlayNote / afterPlayNote contract, so the `_windOrigPlayNote`
// workaround is gone and the mixin applies like any other.
-if (typeof KeyboardWindMixin !== 'undefined') _applyMixin('Wind', KeyboardWindMixin);
+if (typeof KeyboardWindMixin !== 'undefined') _applyMixin('Wind', KeyboardWindMixin);
// ─── Tracked DOM listeners (Phase E helper, KM-M3) ──────────────────────────
// Subsequent code can call `this._on(el, 'click', fn)` instead of
@@ -1673,21 +1829,25 @@ if (typeof KeyboardWindMixin !== 'undefined') _applyMixin('Wind', KeyboardW
// Existing call sites are not migrated yet — this is opt-in for new code.
KeyboardModal.prototype._on = function (el, evt, handler, opts) {
- if (!el || !evt || typeof handler !== 'function') return;
- el.addEventListener(evt, handler, opts);
- (this._trackedListeners ||= []).push([el, evt, handler, opts]);
+ if (!el || !evt || typeof handler !== 'function') return;
+ el.addEventListener(evt, handler, opts);
+ (this._trackedListeners ||= []).push([el, evt, handler, opts]);
};
KeyboardModal.prototype._offAll = function () {
- if (!this._trackedListeners) return;
- for (const [el, evt, h, o] of this._trackedListeners) {
- try { el.removeEventListener(evt, h, o); } catch (_) { /* ignore */ }
+ if (!this._trackedListeners) return;
+ for (const [el, evt, h, o] of this._trackedListeners) {
+ try {
+ el.removeEventListener(evt, h, o);
+ } catch (_) {
+ /* ignore */
}
- this._trackedListeners.length = 0;
+ }
+ this._trackedListeners.length = 0;
};
// Expose the class on window so test runners + late-loading helpers can
// inspect prototype.
if (typeof window !== 'undefined') {
- window.KeyboardModal = KeyboardModal;
+ window.KeyboardModal = KeyboardModal;
}
diff --git a/public/js/features/LightingControlPage.js b/public/js/features/LightingControlPage.js
index 94cca6e58..bd1eb30ec 100644
--- a/public/js/features/LightingControlPage.js
+++ b/public/js/features/LightingControlPage.js
@@ -61,9 +61,20 @@ class LightingControlPage {
`;
document.body.appendChild(overlay);
- overlay.querySelector('#_lcpConfirmYes').onclick = () => { overlay.remove(); resolve(true); };
- overlay.querySelector('#_lcpConfirmNo').onclick = () => { overlay.remove(); resolve(false); };
- overlay.addEventListener('click', (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } });
+ overlay.querySelector('#_lcpConfirmYes').onclick = () => {
+ overlay.remove();
+ resolve(true);
+ };
+ overlay.querySelector('#_lcpConfirmNo').onclick = () => {
+ overlay.remove();
+ resolve(false);
+ };
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) {
+ overlay.remove();
+ resolve(false);
+ }
+ });
});
}
@@ -109,9 +120,15 @@ class LightingControlPage {
}
// Remove any open sub-panels
- ['lightingDeviceForm', 'lightingRuleForm', 'lightingPresetsPanel',
- 'lightingEffectsPanel', 'lightingGroupsPanel', 'lightingScanPanel',
- 'lightingColorWheel'].forEach(id => {
+ [
+ 'lightingDeviceForm',
+ 'lightingRuleForm',
+ 'lightingPresetsPanel',
+ 'lightingEffectsPanel',
+ 'lightingGroupsPanel',
+ 'lightingScanPanel',
+ 'lightingColorWheel'
+ ].forEach((id) => {
document.getElementById(id)?.remove();
});
@@ -244,7 +261,9 @@ class LightingControlPage {
this._setupEventDelegation();
// Close on overlay click
- this.modal.addEventListener('click', (e) => { if (e.target === this.modal) this.close(); });
+ this.modal.addEventListener('click', (e) => {
+ if (e.target === this.modal) this.close();
+ });
// Responsive
this._checkResponsive();
@@ -265,11 +284,20 @@ class LightingControlPage {
// Simple actions (no params)
const simpleMethods = [
- 'showEffectsPanel', 'showGroupsPanel', 'showPresetsPanel',
- 'blackout', 'allOff', 'close', 'testDevice',
- 'showAddDeviceForm', 'showEditDeviceForm', 'showAddRuleForm',
- 'scanDevices', 'reconnectDevice',
- '_testPreviewRainbow', '_clearPreview',
+ 'showEffectsPanel',
+ 'showGroupsPanel',
+ 'showPresetsPanel',
+ 'blackout',
+ 'allOff',
+ 'close',
+ 'testDevice',
+ 'showAddDeviceForm',
+ 'showEditDeviceForm',
+ 'showAddRuleForm',
+ 'scanDevices',
+ 'reconnectDevice',
+ '_testPreviewRainbow',
+ '_clearPreview',
'toggleLightingEnabled'
];
if (simpleMethods.includes(action) && typeof this[action] === 'function') {
@@ -327,7 +355,12 @@ class LightingControlPage {
if (w < 640) {
if (tabs) tabs.style.display = 'flex';
if (this.mobilePanelView === 'devices') {
- if (devicePanel) { devicePanel.style.display = 'flex'; devicePanel.style.width = '100%'; devicePanel.style.minWidth = '0'; devicePanel.style.borderRight = 'none'; }
+ if (devicePanel) {
+ devicePanel.style.display = 'flex';
+ devicePanel.style.width = '100%';
+ devicePanel.style.minWidth = '0';
+ devicePanel.style.borderRight = 'none';
+ }
if (rulesPanel) rulesPanel.style.display = 'none';
} else {
if (devicePanel) devicePanel.style.display = 'none';
@@ -335,7 +368,12 @@ class LightingControlPage {
}
} else {
if (tabs) tabs.style.display = 'none';
- if (devicePanel) { devicePanel.style.display = 'flex'; devicePanel.style.width = ''; devicePanel.style.minWidth = ''; devicePanel.style.borderRight = ''; }
+ if (devicePanel) {
+ devicePanel.style.display = 'flex';
+ devicePanel.style.width = '';
+ devicePanel.style.minWidth = '';
+ devicePanel.style.borderRight = '';
+ }
if (rulesPanel) rulesPanel.style.display = 'flex';
}
}
@@ -391,28 +429,40 @@ class LightingControlPage {
try {
const res = await this.apiClient.sendCommand('instrument_light_list');
states = (res && res.instruments) || [];
- } catch (e) { /* no persisted states yet */ }
+ } catch (e) {
+ /* no persisted states yet */
+ }
const enabled = (this.instruments || []).filter(
(i) => i.lighting_enabled === true || i.lighting_enabled === 1
);
if (enabled.length === 0) {
host.innerHTML = `
- ${i18n.t('lighting.noLitInstruments') || 'Aucun instrument avec le contrôle lumière activé. Activez-le depuis les réglages d\'un instrument (onglet « Notes & Capacités »).'}
+ ${i18n.t('lighting.noLitInstruments') || "Aucun instrument avec le contrôle lumière activé. Activez-le depuis les réglages d'un instrument (onglet « Notes & Capacités »)."}
`;
return;
}
const defaults = {
- brightness: 0, effect: 0, hue: 0, speed: 64, intensity: 64,
- supported_mask: 0, brightness_mode: 1, supported_effects: 0x3FF
+ brightness: 0,
+ effect: 0,
+ hue: 0,
+ speed: 64,
+ intensity: 64,
+ supported_mask: 0,
+ brightness_mode: 1,
+ supported_effects: 0x3ff
};
- host.innerHTML = enabled.map((inst) => {
- const name = inst.custom_name || inst.name || inst.device_id;
- const ch = inst.channel || 0;
- const stored = states.find((s) => s.device_id === inst.device_id && (s.channel || 0) === ch);
- const st = { ...defaults, ...(stored || {}) };
- return this._renderLitPanel(inst.device_id, ch, name, st);
- }).join('');
+ host.innerHTML = enabled
+ .map((inst) => {
+ const name = inst.custom_name || inst.name || inst.device_id;
+ const ch = inst.channel || 0;
+ const stored = states.find(
+ (s) => s.device_id === inst.device_id && (s.channel || 0) === ch
+ );
+ const st = { ...defaults, ...(stored || {}) };
+ return this._renderLitPanel(inst.device_id, ch, name, st);
+ })
+ .join('');
this._attachLitControlListeners();
}
@@ -427,7 +477,7 @@ class LightingControlPage {
⚙️ `;
if (mask === 0) {
@@ -439,7 +489,7 @@ class LightingControlPage {
${cogBtn}
- ${esc(t('lighting.litNoCcDeclared', 'Aucun CC déclaré pour cet instrument. Activez les CC supportés dans les réglages de l\'instrument.'))}
+ ${esc(t('lighting.litNoCcDeclared', "Aucun CC déclaré pour cet instrument. Activez les CC supportés dans les réglages de l'instrument."))}
`;
}
@@ -447,14 +497,12 @@ class LightingControlPage {
// Summary line: a discrete read-only list of every supported CC.
const SUPPORTED = [
{ bit: 0x01, cc: 110, label: t('lighting.lit.brightness', 'Lum.') },
- { bit: 0x02, cc: 111, label: t('lighting.lit.effect', 'Effet') },
- { bit: 0x04, cc: 112, label: t('lighting.lit.hue', 'Couleur') },
- { bit: 0x08, cc: 113, label: t('lighting.lit.speed', 'Vit.') },
- { bit: 0x10, cc: 114, label: t('lighting.lit.intensity', 'Int.') }
+ { bit: 0x02, cc: 111, label: t('lighting.lit.effect', 'Effet') },
+ { bit: 0x04, cc: 112, label: t('lighting.lit.hue', 'Couleur') },
+ { bit: 0x08, cc: 113, label: t('lighting.lit.speed', 'Vit.') },
+ { bit: 0x10, cc: 114, label: t('lighting.lit.intensity', 'Int.') }
].filter((b) => (mask & b.bit) !== 0);
- const summary = SUPPORTED
- .map((b) => `CC${b.cc} `)
- .join(' · ');
+ const summary = SUPPORTED.map((b) => `CC${b.cc} `).join(' · ');
const rows = SUPPORTED.map((b) => this._renderLitRow(b, st, ds)).join('');
@@ -488,12 +536,14 @@ class LightingControlPage {
const ICONS = { brightness: '💡', effect: '🎬', hue: '🎨', speed: '⏩', intensity: '✨' };
const FULL_LABEL = {
brightness: t('lighting.litFull.brightness', 'Luminosité'),
- effect: t('lighting.litFull.effect', 'Effet'),
- hue: t('lighting.litFull.hue', 'Couleur'),
- speed: t('lighting.litFull.speed', 'Vitesse'),
- intensity: t('lighting.litFull.intensity', 'Intensité')
+ effect: t('lighting.litFull.effect', 'Effet'),
+ hue: t('lighting.litFull.hue', 'Couleur'),
+ speed: t('lighting.litFull.speed', 'Vitesse'),
+ intensity: t('lighting.litFull.intensity', 'Intensité')
};
- const field = b.field || ({ 110: 'brightness', 111: 'effect', 112: 'hue', 113: 'speed', 114: 'intensity' }[b.cc]);
+ const field =
+ b.field ||
+ { 110: 'brightness', 111: 'effect', 112: 'hue', 113: 'speed', 114: 'intensity' }[b.cc];
const labelHtml = `
${ICONS[field]}
@@ -517,26 +567,39 @@ class LightingControlPage {
style="flex:1;padding:6px 12px;border-radius:16px;cursor:pointer;font-weight:600;
border:1px solid ${on ? '#10b981' : 'var(--lt-border,#d1d5db)'};
background:${on ? 'rgba(16,185,129,0.15)' : 'transparent'};color:inherit;">
- ${on ? (i18n.t('lighting.litOnOffOn') || 'ON') : (i18n.t('lighting.litOnOffOff') || 'OFF')}
+ ${on ? i18n.t('lighting.litOnOffOn') || 'ON' : i18n.t('lighting.litOnOffOff') || 'OFF'}
`;
}
if (b.field === 'effect') {
const effects = [
- 'static', 'fade', 'pulse', 'blink', 'rainbow',
- 'reactive_note', 'reactive_velocity', 'sparkle', 'fire', 'scanner'
+ 'static',
+ 'fade',
+ 'pulse',
+ 'blink',
+ 'rainbow',
+ 'reactive_note',
+ 'reactive_velocity',
+ 'sparkle',
+ 'fire',
+ 'scanner'
];
- const fxMask = st.supported_effects === undefined || st.supported_effects === null
- ? 0x3FF : (st.supported_effects | 0);
+ const fxMask =
+ st.supported_effects === undefined || st.supported_effects === null
+ ? 0x3ff
+ : st.supported_effects | 0;
const opts = effects
.map((key, i) => ({ key, i }))
.filter(({ i }) => (fxMask & (1 << i)) !== 0)
.map(({ key, i }) => {
const label = i18n.t('lighting.lightEffect.' + key) || key;
return `${label} `;
- }).join('');
+ })
+ .join('');
const empty = opts.length === 0;
return `${
- empty ? `${i18n.t('lighting.litNoEffects') || 'Aucun effet déclaré'} ` : opts
+ empty
+ ? `${i18n.t('lighting.litNoEffects') || 'Aucun effet déclaré'} `
+ : opts
} `;
}
if (b.field === 'hue') {
@@ -615,14 +678,18 @@ class LightingControlPage {
el.classList.toggle('is-on', !isOn);
el.setAttribute('aria-pressed', String(!isOn));
el.textContent = !isOn
- ? (i18n.t('lighting.litOnOffOn') || 'ON')
- : (i18n.t('lighting.litOnOffOff') || 'OFF');
+ ? i18n.t('lighting.litOnOffOn') || 'ON'
+ : i18n.t('lighting.litOnOffOff') || 'OFF';
el.style.background = !isOn ? 'rgba(16,185,129,0.15)' : 'transparent';
el.style.borderColor = !isOn ? '#10b981' : 'var(--lt-border,#d1d5db)';
setState(el, { brightness: next });
- }));
- host.querySelectorAll('.lit-effect').forEach((el) =>
- el.addEventListener('change', () => setState(el, { effect: parseInt(el.value, 10) })));
+ })
+ );
+ host
+ .querySelectorAll('.lit-effect')
+ .forEach((el) =>
+ el.addEventListener('change', () => setState(el, { effect: parseInt(el.value, 10) }))
+ );
rangeChange('.lit-hue', 'hue', (el) => {
const swatch = rowOf(el)?.querySelector('.lit-hue-swatch');
if (swatch) swatch.style.background = LightingControlPage._hueToCss(parseInt(el.value, 10));
@@ -634,27 +701,43 @@ class LightingControlPage {
/** HSV hue 0-127 → CSS color string. Static so it can run in tests. */
static _hueToCss(hue127) {
const h = ((Math.max(0, Math.min(127, hue127 | 0)) / 128) * 6) % 6;
- const c = 1, x = c * (1 - Math.abs((h % 2) - 1));
- let r = 0, g = 0, b = 0;
- if (h < 1) { r = c; g = x; }
- else if (h < 2) { r = x; g = c; }
- else if (h < 3) { g = c; b = x; }
- else if (h < 4) { g = x; b = c; }
- else if (h < 5) { r = x; b = c; }
- else { r = c; b = x; }
+ const c = 1,
+ x = c * (1 - Math.abs((h % 2) - 1));
+ let r = 0,
+ g = 0,
+ b = 0;
+ if (h < 1) {
+ r = c;
+ g = x;
+ } else if (h < 2) {
+ r = x;
+ g = c;
+ } else if (h < 3) {
+ g = c;
+ b = x;
+ } else if (h < 4) {
+ g = x;
+ b = c;
+ } else if (h < 5) {
+ r = x;
+ b = c;
+ } else {
+ r = c;
+ b = x;
+ }
const to = (v) => Math.round(v * 255);
return `rgb(${to(r)}, ${to(g)}, ${to(b)})`;
}
_openInstrumentSettings(deviceId, channel) {
const inst = (this.instruments || []).find(
- i => i.device_id === deviceId && (i.channel || 0) === channel
+ (i) => i.device_id === deviceId && (i.channel || 0) === channel
);
const device = {
id: deviceId,
channel: channel,
- name: inst ? (inst.name || deviceId) : deviceId,
- displayName: inst ? (inst.custom_name || inst.name || deviceId) : deviceId
+ name: inst ? inst.name || deviceId : deviceId,
+ displayName: inst ? inst.custom_name || inst.name || deviceId : deviceId
};
// Close the lighting modal first to avoid stacked modals; the
// per-instrument config is the single source of truth and lives there.
@@ -704,13 +787,13 @@ class LightingControlPage {
this._updateToggleBtn();
this.showToast(
this.lightingEnabled
- ? (i18n.t('lighting.lightsOnToast') || 'Lumières allumées')
- : (i18n.t('lighting.lightsOffToast') || 'Lumières éteintes'),
+ ? i18n.t('lighting.lightsOnToast') || 'Lumières allumées'
+ : i18n.t('lighting.lightsOffToast') || 'Lumières éteintes',
this.lightingEnabled ? 'success' : 'warning'
);
} catch (error) {
console.error('Failed to toggle lighting system:', error);
- this.showToast(i18n.t('lighting.toggleError') || 'Erreur lors du changement d\'état', 'error');
+ this.showToast(i18n.t('lighting.toggleError') || "Erreur lors du changement d'état", 'error');
}
}
@@ -742,25 +825,46 @@ class LightingControlPage {
async _createGroup() {
const name = document.getElementById('lgFormName')?.value.trim();
- if (!name) { this.showToast(i18n.t('lighting.nameRequired') || 'Nom requis', 'warning'); return; }
+ if (!name) {
+ this.showToast(i18n.t('lighting.nameRequired') || 'Nom requis', 'warning');
+ return;
+ }
const checkboxes = document.querySelectorAll('#lightingGroupsPanel .lgDeviceCb:checked');
- const deviceIds = [...checkboxes].map(cb => parseInt(cb.value));
- if (deviceIds.length === 0) { this.showToast(i18n.t('lighting.selectAtLeastOneDevice') || 'Sélectionnez au moins un dispositif', 'warning'); return; }
+ const deviceIds = [...checkboxes].map((cb) => parseInt(cb.value));
+ if (deviceIds.length === 0) {
+ this.showToast(
+ i18n.t('lighting.selectAtLeastOneDevice') || 'Sélectionnez au moins un dispositif',
+ 'warning'
+ );
+ return;
+ }
try {
await this.apiClient.sendCommand('lighting_group_create', { name, device_ids: deviceIds });
this.showGroupsPanel();
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async _deleteGroupByIdx(idx) {
const name = this._groupNames?.[idx];
if (!name) return;
- if (!await this._confirm((i18n.t('lighting.confirmDeleteGroup') || 'Supprimer le groupe « {name} » ?').replace('{name}', name))) return;
+ if (
+ !(await this._confirm(
+ (i18n.t('lighting.confirmDeleteGroup') || 'Supprimer le groupe « {name} » ?').replace(
+ '{name}',
+ name
+ )
+ ))
+ )
+ return;
try {
await this.apiClient.sendCommand('lighting_group_delete', { name });
this.showGroupsPanel();
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async _setGroupColorByIdx(idx) {
@@ -770,7 +874,9 @@ class LightingControlPage {
const color = colorInput?.value || '#FF0000';
try {
await this.apiClient.sendCommand('lighting_group_color', { name, color, brightness: 255 });
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async _groupOffByIdx(idx) {
@@ -778,13 +884,15 @@ class LightingControlPage {
if (!name) return;
try {
await this.apiClient.sendCommand('lighting_group_off', { name });
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
// ==================== DEVICE CLONE ====================
async cloneDevice(deviceId) {
- const device = this.devices.find(d => d.id === deviceId);
+ const device = this.devices.find((d) => d.id === deviceId);
if (!device) return;
try {
@@ -796,7 +904,9 @@ class LightingControlPage {
enabled: false // Start disabled to avoid conflicts
});
await this.loadData();
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async _startLiveEffect() {
@@ -810,7 +920,9 @@ class LightingControlPage {
await this.apiClient.sendCommand('lighting_effect_start', {
device_id: this.selectedDeviceId,
effect_type: effectType,
- color, speed, brightness
+ color,
+ speed,
+ brightness
});
// Refresh the panel
this.showEffectsPanel();
@@ -826,7 +938,9 @@ class LightingControlPage {
const inputEl = document.getElementById('leEffectBpmInput');
if (bpmEl) bpmEl.textContent = res.bpm;
if (inputEl) inputEl.value = res.bpm;
- } catch (e) { /* ignore */ }
+ } catch (e) {
+ /* ignore */
+ }
}
async _setBpm(value) {
@@ -834,7 +948,9 @@ class LightingControlPage {
const res = await this.apiClient.sendCommand('lighting_bpm_set', { bpm: parseInt(value) });
const bpmEl = document.getElementById('leEffectBpm');
if (bpmEl) bpmEl.textContent = res.bpm;
- } catch (e) { /* ignore */ }
+ } catch (e) {
+ /* ignore */
+ }
}
async _stopLiveEffect(effectKey) {
@@ -859,12 +975,17 @@ class LightingControlPage {
a.download = `lighting-rules-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async savePreset() {
const name = document.getElementById('lpFormName')?.value.trim();
- if (!name) { this.showToast(i18n.t('lighting.presetName') || 'Nom requis', 'warning'); return; }
+ if (!name) {
+ this.showToast(i18n.t('lighting.presetName') || 'Nom requis', 'warning');
+ return;
+ }
try {
await this.apiClient.sendCommand('lighting_preset_save', { name });
document.getElementById('lightingPresetsPanel')?.remove();
@@ -877,7 +998,13 @@ class LightingControlPage {
}
async loadPreset(id) {
- if (!await this._confirm(i18n.t('lighting.confirmLoadPreset') || 'Charger ce preset ? Les règles actuelles seront remplacées.')) return;
+ if (
+ !(await this._confirm(
+ i18n.t('lighting.confirmLoadPreset') ||
+ 'Charger ce preset ? Les règles actuelles seront remplacées.'
+ ))
+ )
+ return;
try {
await this.apiClient.sendCommand('lighting_preset_load', { id });
document.getElementById('lightingPresetsPanel')?.remove();
@@ -888,7 +1015,8 @@ class LightingControlPage {
}
async deletePreset(id) {
- if (!await this._confirm(i18n.t('lighting.confirmDeletePreset') || 'Supprimer ce preset ?')) return;
+ if (!(await this._confirm(i18n.t('lighting.confirmDeletePreset') || 'Supprimer ce preset ?')))
+ return;
try {
await this.apiClient.sendCommand('lighting_preset_delete', { id });
document.getElementById('lightingPresetsPanel')?.remove();
@@ -902,10 +1030,16 @@ class LightingControlPage {
async saveScene() {
const name = document.getElementById('lpSceneName')?.value.trim();
- if (!name) { this.showToast(i18n.t('lighting.sceneName') || 'Nom requis', 'warning'); return; }
+ if (!name) {
+ this.showToast(i18n.t('lighting.sceneName') || 'Nom requis', 'warning');
+ return;
+ }
try {
await this.apiClient.sendCommand('lighting_scene_save', { name });
- this.showToast((i18n.t('lighting.sceneSaved') || 'Scène « {name} » sauvegardée').replace('{name}', name), 'success');
+ this.showToast(
+ (i18n.t('lighting.sceneSaved') || 'Scène « {name} » sauvegardée').replace('{name}', name),
+ 'success'
+ );
document.getElementById('lightingPresetsPanel')?.remove();
const res = await this.apiClient.sendCommand('lighting_preset_list');
this.presets = res.presets || [];
@@ -925,11 +1059,17 @@ class LightingControlPage {
this._dmxProfiles = res.profiles || [];
}
- select.innerHTML = `${i18n.t('lighting.manualOption') || '-- Manuel --'} ` +
- this._dmxProfiles.map(p =>
- `${this._escapeHtml(p.name)} (${p.channels}ch) `
- ).join('');
- } catch (e) { /* ignore - profiles not available */ }
+ select.innerHTML =
+ `${i18n.t('lighting.manualOption') || '-- Manuel --'} ` +
+ this._dmxProfiles
+ .map(
+ (p) =>
+ `${this._escapeHtml(p.name)} (${p.channels}ch) `
+ )
+ .join('');
+ } catch (e) {
+ /* ignore - profiles not available */
+ }
}
_onDmxProfileChange(deviceType) {
@@ -939,7 +1079,7 @@ class LightingControlPage {
const channelsInput = document.getElementById(channelsId);
if (!select || !channelsInput || !this._dmxProfiles) return;
- const profile = this._dmxProfiles.find(p => p.key === select.value);
+ const profile = this._dmxProfiles.find((p) => p.key === select.value);
if (profile) {
channelsInput.value = profile.channels;
}
@@ -950,7 +1090,9 @@ class LightingControlPage {
const gpioSelect = selectEl.closest('.strip-entry').querySelector('.strip-gpio');
const gpioMap = { 0: [18, 12], 1: [13, 19], 2: [10] };
const pins = gpioMap[ch] || [];
- gpioSelect.innerHTML = pins.map((p, i) => `GPIO ${p} `).join('');
+ gpioSelect.innerHTML = pins
+ .map((p, i) => `GPIO ${p} `)
+ .join('');
}
_addSegmentEntry() {
@@ -970,10 +1112,18 @@ class LightingControlPage {
}
async deleteDevice(id) {
- if (!await this._confirm(i18n.t('lighting.confirmDeleteDevice') || 'Supprimer ce dispositif et toutes ses règles ?')) return;
+ if (
+ !(await this._confirm(
+ i18n.t('lighting.confirmDeleteDevice') || 'Supprimer ce dispositif et toutes ses règles ?'
+ ))
+ )
+ return;
try {
await this.apiClient.sendCommand('lighting_device_delete', { id });
- if (this.selectedDeviceId === id) { this.selectedDeviceId = null; this.rules = []; }
+ if (this.selectedDeviceId === id) {
+ this.selectedDeviceId = null;
+ this.rules = [];
+ }
await this.loadData();
} catch (error) {
this.showToast(error.message, 'error');
@@ -993,19 +1143,28 @@ class LightingControlPage {
async reconnectDevice() {
if (!this.selectedDeviceId) return;
const btn = document.getElementById('lightingReconnectBtn');
- if (btn) { btn.textContent = `⏳ ${i18n.t('lighting.reconnecting') || 'Reconnexion...'}`; btn.disabled = true; }
+ if (btn) {
+ btn.textContent = `⏳ ${i18n.t('lighting.reconnecting') || 'Reconnexion...'}`;
+ btn.disabled = true;
+ }
try {
- await this.apiClient.sendCommand('lighting_device_update', { id: this.selectedDeviceId, enabled: true });
+ await this.apiClient.sendCommand('lighting_device_update', {
+ id: this.selectedDeviceId,
+ enabled: true
+ });
await this.loadData();
} catch (error) {
this.showToast(error.message, 'error');
} finally {
- if (btn) { btn.textContent = `🔄 ${i18n.t('lighting.reconnect') || 'Reconnecter'}`; btn.disabled = false; }
+ if (btn) {
+ btn.textContent = `🔄 ${i18n.t('lighting.reconnect') || 'Reconnecter'}`;
+ btn.disabled = false;
+ }
}
}
_populateSegmentDropdown(selectedSegment) {
- const device = this.devices.find(d => d.id === this.selectedDeviceId);
+ const device = this.devices.find((d) => d.id === this.selectedDeviceId);
const segRow = document.getElementById('lrFormSegmentRow');
const segSelect = document.getElementById('lrFormSegment');
if (!segRow || !segSelect || !device) return;
@@ -1013,8 +1172,14 @@ class LightingControlPage {
if (device.type === 'gpio_strip' && device.connection_config?.segments?.length) {
segRow.style.display = 'block';
const segments = device.connection_config.segments;
- segSelect.innerHTML = `${i18n.t('lighting.manualSegmentOption') || '-- Aucun (manuel) --'} ` +
- segments.map(s => `${this._escapeHtml(s.name)} (${s.start}-${s.end}) `).join('');
+ segSelect.innerHTML =
+ `${i18n.t('lighting.manualSegmentOption') || '-- Aucun (manuel) --'} ` +
+ segments
+ .map(
+ (s) =>
+ `${this._escapeHtml(s.name)} (${s.start}-${s.end}) `
+ )
+ .join('');
if (selectedSegment) this._onSegmentSelect();
} else {
segRow.style.display = 'none';
@@ -1022,11 +1187,11 @@ class LightingControlPage {
}
_onSegmentSelect() {
- const device = this.devices.find(d => d.id === this.selectedDeviceId);
+ const device = this.devices.find((d) => d.id === this.selectedDeviceId);
const segName = document.getElementById('lrFormSegment')?.value;
if (!segName || !device?.connection_config?.segments) return;
- const seg = device.connection_config.segments.find(s => s.name === segName);
+ const seg = device.connection_config.segments.find((s) => s.name === segName);
if (seg) {
const startEl = document.getElementById('lrFormLedStart');
const endEl = document.getElementById('lrFormLedEnd');
@@ -1047,7 +1212,13 @@ class LightingControlPage {
const nl = document.getElementById('lrFormNoteLedSection');
// Color picker: show for most types, hide for special modes
- const hideColor = ['velocity_mapped', 'note_color', 'color_temp', 'random_color', 'note_led'].includes(type);
+ const hideColor = [
+ 'velocity_mapped',
+ 'note_color',
+ 'color_temp',
+ 'random_color',
+ 'note_led'
+ ].includes(type);
if (s) s.style.display = hideColor ? 'none' : 'block';
if (g) g.style.display = type === 'velocity_mapped' ? 'block' : 'none';
if (e) e.style.display = isEffect ? 'block' : 'none';
@@ -1069,12 +1240,12 @@ class LightingControlPage {
// _clamp is provided by LightingHelpersMixin
async editRule(ruleId) {
- const rule = this.rules.find(r => r.id === ruleId);
+ const rule = this.rules.find((r) => r.id === ruleId);
if (rule) this.showAddRuleForm(rule);
}
async cloneRule(ruleId) {
- const rule = this.rules.find(r => r.id === ruleId);
+ const rule = this.rules.find((r) => r.id === ruleId);
if (!rule) return;
try {
await this.apiClient.sendCommand('lighting_rule_add', {
@@ -1087,42 +1258,55 @@ class LightingControlPage {
action_config: rule.action_config
});
await this.loadRulesForDevice(this.selectedDeviceId);
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async deleteRule(id) {
- if (!await this._confirm(i18n.t('lighting.confirmDeleteRule') || 'Supprimer cette règle ?')) return;
+ if (!(await this._confirm(i18n.t('lighting.confirmDeleteRule') || 'Supprimer cette règle ?')))
+ return;
try {
await this.apiClient.sendCommand('lighting_rule_delete', { id });
await this.loadRulesForDevice(this.selectedDeviceId);
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async toggleRule(id, enabled) {
try {
await this.apiClient.sendCommand('lighting_rule_update', { id, enabled });
await this.loadRulesForDevice(this.selectedDeviceId);
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async batchToggleRules(enabled) {
try {
const updates = this.rules
- .filter(rule => rule.enabled !== enabled)
- .map(rule => this.apiClient.sendCommand('lighting_rule_update', { id: rule.id, enabled }));
+ .filter((rule) => rule.enabled !== enabled)
+ .map((rule) =>
+ this.apiClient.sendCommand('lighting_rule_update', { id: rule.id, enabled })
+ );
await Promise.all(updates);
await this.loadRulesForDevice(this.selectedDeviceId);
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
async moveRulePriority(id, delta) {
- const rule = this.rules.find(r => r.id === id);
+ const rule = this.rules.find((r) => r.id === id);
if (!rule) return;
const newPriority = (rule.priority || 0) + delta;
try {
await this.apiClient.sendCommand('lighting_rule_update', { id, priority: newPriority });
await this.loadRulesForDevice(this.selectedDeviceId);
- } catch (error) { this.showToast(error.message, 'error'); }
+ } catch (error) {
+ this.showToast(error.message, 'error');
+ }
}
// Actions (testDevice, testRule, allOff, blackout, _onMasterDimmerChange)
@@ -1137,16 +1321,16 @@ class LightingControlPage {
// this file. Methods defined directly on the class take precedence over mixin
// methods (they are applied first, class methods shadow them).
// ============================================================================
-(function() {
+(function () {
const mixins = [
window.LightingHelpersMixin,
window.LightingFormsMixin,
window.LightingDeviceUIMixin,
window.LightingPresetsUIMixin
];
- mixins.forEach(mixin => {
+ mixins.forEach((mixin) => {
if (!mixin) return;
- Object.keys(mixin).forEach(key => {
+ Object.keys(mixin).forEach((key) => {
// Only add mixin method if NOT already defined on the class prototype
// This avoids overwriting class methods with mixin duplicates
if (!Object.hasOwn(LightingControlPage.prototype, key)) {
diff --git a/public/js/features/LoopCreatorMinimap.js b/public/js/features/LoopCreatorMinimap.js
index 7b28ea99b..b04674b5a 100644
--- a/public/js/features/LoopCreatorMinimap.js
+++ b/public/js/features/LoopCreatorMinimap.js
@@ -22,250 +22,292 @@
* m.draw(); // force immediate redraw
* m.destroy();
*/
-(function() {
- 'use strict';
+(function () {
+ 'use strict';
- class LoopCreatorMinimap {
- constructor(canvas, opts = {}) {
- this.canvas = canvas;
- this.ppq = Number.isFinite(opts.ppq) && opts.ppq > 0 ? opts.ppq : 480;
- this.timeSigNum = Number.isFinite(opts.timeSigNum) && opts.timeSigNum > 0 ? opts.timeSigNum : 4;
- this.bars = Number.isFinite(opts.bars) && opts.bars > 0 ? opts.bars : 2;
- this.noteMin = Number.isFinite(opts.noteMin) ? opts.noteMin : 36;
- this.noteMax = Number.isFinite(opts.noteMax) ? opts.noteMax : 84;
- this.onSeek = typeof opts.onSeek === 'function' ? opts.onSeek : null;
+ class LoopCreatorMinimap {
+ constructor(canvas, opts = {}) {
+ this.canvas = canvas;
+ this.ppq = Number.isFinite(opts.ppq) && opts.ppq > 0 ? opts.ppq : 480;
+ this.timeSigNum =
+ Number.isFinite(opts.timeSigNum) && opts.timeSigNum > 0 ? opts.timeSigNum : 4;
+ this.bars = Number.isFinite(opts.bars) && opts.bars > 0 ? opts.bars : 2;
+ this.noteMin = Number.isFinite(opts.noteMin) ? opts.noteMin : 36;
+ this.noteMax = Number.isFinite(opts.noteMax) ? opts.noteMax : 84;
+ this.onSeek = typeof opts.onSeek === 'function' ? opts.onSeek : null;
- this._notes = [];
- this._xoffset = 0;
- this._xrange = 0;
- this._cursor = 0;
- this._isPlaying = false;
- this._dragging = false;
- this._dirty = false;
- this._rafHandle = null;
+ this._notes = [];
+ this._xoffset = 0;
+ this._xrange = 0;
+ this._cursor = 0;
+ this._isPlaying = false;
+ this._dragging = false;
+ this._dirty = false;
+ this._rafHandle = null;
- if (canvas?.addEventListener) {
- this._onMouseDown = (e) => { this._dragging = true; this._seek(e.clientX); };
- this._onMouseMove = (e) => { if (this._dragging) this._seek(e.clientX); };
- this._onMouseUp = () => { this._dragging = false; };
- this._onMouseLeave = () => { this._dragging = false; };
- this._onTouchStart = (e) => { e.preventDefault(); this._dragging = true; this._seek(e.touches[0].clientX); };
- this._onTouchMove = (e) => { e.preventDefault(); if (this._dragging) this._seek(e.touches[0].clientX); };
- this._onTouchEnd = () => { this._dragging = false; };
- this._onKeyDown = (e) => this._handleKeyDown(e);
+ if (canvas?.addEventListener) {
+ this._onMouseDown = (e) => {
+ this._dragging = true;
+ this._seek(e.clientX);
+ };
+ this._onMouseMove = (e) => {
+ if (this._dragging) this._seek(e.clientX);
+ };
+ this._onMouseUp = () => {
+ this._dragging = false;
+ };
+ this._onMouseLeave = () => {
+ this._dragging = false;
+ };
+ this._onTouchStart = (e) => {
+ e.preventDefault();
+ this._dragging = true;
+ this._seek(e.touches[0].clientX);
+ };
+ this._onTouchMove = (e) => {
+ e.preventDefault();
+ if (this._dragging) this._seek(e.touches[0].clientX);
+ };
+ this._onTouchEnd = () => {
+ this._dragging = false;
+ };
+ this._onKeyDown = (e) => this._handleKeyDown(e);
- canvas.addEventListener('mousedown', this._onMouseDown);
- canvas.addEventListener('mousemove', this._onMouseMove);
- canvas.addEventListener('mouseup', this._onMouseUp);
- canvas.addEventListener('mouseleave', this._onMouseLeave);
- canvas.addEventListener('touchstart', this._onTouchStart, { passive: false });
- canvas.addEventListener('touchmove', this._onTouchMove, { passive: false });
- canvas.addEventListener('touchend', this._onTouchEnd);
- canvas.addEventListener('keydown', this._onKeyDown);
- }
- }
+ canvas.addEventListener('mousedown', this._onMouseDown);
+ canvas.addEventListener('mousemove', this._onMouseMove);
+ canvas.addEventListener('mouseup', this._onMouseUp);
+ canvas.addEventListener('mouseleave', this._onMouseLeave);
+ canvas.addEventListener('touchstart', this._onTouchStart, { passive: false });
+ canvas.addEventListener('touchmove', this._onTouchMove, { passive: false });
+ canvas.addEventListener('touchend', this._onTouchEnd);
+ canvas.addEventListener('keydown', this._onKeyDown);
+ }
+ }
- // -----------------------------------------------------------------
- // Public setters — each schedules a coalesced rAF draw
- // -----------------------------------------------------------------
+ // -----------------------------------------------------------------
+ // Public setters — each schedules a coalesced rAF draw
+ // -----------------------------------------------------------------
- setNotes(seq) {
- this._notes = Array.isArray(seq) ? seq : [];
- this._scheduleDraw();
- }
+ setNotes(seq) {
+ this._notes = Array.isArray(seq) ? seq : [];
+ this._scheduleDraw();
+ }
- setViewport(xoffset, xrange) {
- this._xoffset = Number.isFinite(xoffset) ? xoffset : 0;
- this._xrange = Number.isFinite(xrange) ? xrange : 0;
- this._scheduleDraw();
- }
+ setViewport(xoffset, xrange) {
+ this._xoffset = Number.isFinite(xoffset) ? xoffset : 0;
+ this._xrange = Number.isFinite(xrange) ? xrange : 0;
+ this._scheduleDraw();
+ }
- setPlayhead(cursor, isPlaying) {
- this._cursor = Number.isFinite(cursor) ? cursor : 0;
- this._isPlaying = !!isPlaying;
- this._scheduleDraw();
- }
+ setPlayhead(cursor, isPlaying) {
+ this._cursor = Number.isFinite(cursor) ? cursor : 0;
+ this._isPlaying = !!isPlaying;
+ this._scheduleDraw();
+ }
- setConfig({ ppq, timeSigNum, bars, noteMin, noteMax } = {}) {
- if (Number.isFinite(ppq) && ppq > 0) this.ppq = ppq;
- if (Number.isFinite(timeSigNum) && timeSigNum > 0) this.timeSigNum = timeSigNum;
- if (Number.isFinite(bars) && bars > 0) this.bars = bars;
- if (Number.isFinite(noteMin)) this.noteMin = noteMin;
- if (Number.isFinite(noteMax)) this.noteMax = noteMax;
- this._scheduleDraw();
- }
+ setConfig({ ppq, timeSigNum, bars, noteMin, noteMax } = {}) {
+ if (Number.isFinite(ppq) && ppq > 0) this.ppq = ppq;
+ if (Number.isFinite(timeSigNum) && timeSigNum > 0) this.timeSigNum = timeSigNum;
+ if (Number.isFinite(bars) && bars > 0) this.bars = bars;
+ if (Number.isFinite(noteMin)) this.noteMin = noteMin;
+ if (Number.isFinite(noteMax)) this.noteMax = noteMax;
+ this._scheduleDraw();
+ }
- // -----------------------------------------------------------------
- // rAF coalescing
- // -----------------------------------------------------------------
+ // -----------------------------------------------------------------
+ // rAF coalescing
+ // -----------------------------------------------------------------
- _scheduleDraw() {
- this._dirty = true;
- if (this._rafHandle != null) return;
- this._rafHandle = window.requestAnimationFrame(() => {
- this._rafHandle = null;
- if (this._dirty) this.draw();
- });
- }
+ _scheduleDraw() {
+ this._dirty = true;
+ if (this._rafHandle != null) return;
+ this._rafHandle = window.requestAnimationFrame(() => {
+ this._rafHandle = null;
+ if (this._dirty) this.draw();
+ });
+ }
- // -----------------------------------------------------------------
- // Seek interaction
- // -----------------------------------------------------------------
+ // -----------------------------------------------------------------
+ // Seek interaction
+ // -----------------------------------------------------------------
- _seek(clientX) {
- if (!this.canvas || !this.onSeek) return;
- const rect = this.canvas.getBoundingClientRect();
- const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
- const total = this._totalTicks();
- const xrange = this._xrange || total;
- const newOffset = Math.max(0, Math.min(total - xrange,
- Math.round(ratio * total - xrange / 2)));
- this.onSeek(newOffset);
- }
+ _seek(clientX) {
+ if (!this.canvas || !this.onSeek) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
+ const total = this._totalTicks();
+ const xrange = this._xrange || total;
+ const newOffset = Math.max(
+ 0,
+ Math.min(total - xrange, Math.round(ratio * total - xrange / 2))
+ );
+ this.onSeek(newOffset);
+ }
- // Keyboard navigation: Left/Right ±beat, Shift+Left/Right ±bar, Home/End extremes.
- _handleKeyDown(e) {
- if (!this.onSeek) return;
- const total = this._totalTicks();
- const xrange = this._xrange || total;
- const maxOff = Math.max(0, total - xrange);
- const beat = this.ppq;
- const bar = beat * this.timeSigNum;
- const step = e.shiftKey ? bar : beat;
- let next = this._xoffset;
- switch (e.key) {
- case 'ArrowLeft': next = this._xoffset - step; break;
- case 'ArrowRight': next = this._xoffset + step; break;
- case 'Home': next = 0; break;
- case 'End': next = maxOff; break;
- default: return;
- }
- e.preventDefault();
- this.onSeek(Math.max(0, Math.min(maxOff, Math.round(next))));
- }
+ // Keyboard navigation: Left/Right ±beat, Shift+Left/Right ±bar, Home/End extremes.
+ _handleKeyDown(e) {
+ if (!this.onSeek) return;
+ const total = this._totalTicks();
+ const xrange = this._xrange || total;
+ const maxOff = Math.max(0, total - xrange);
+ const beat = this.ppq;
+ const bar = beat * this.timeSigNum;
+ const step = e.shiftKey ? bar : beat;
+ let next = this._xoffset;
+ switch (e.key) {
+ case 'ArrowLeft':
+ next = this._xoffset - step;
+ break;
+ case 'ArrowRight':
+ next = this._xoffset + step;
+ break;
+ case 'Home':
+ next = 0;
+ break;
+ case 'End':
+ next = maxOff;
+ break;
+ default:
+ return;
+ }
+ e.preventDefault();
+ this.onSeek(Math.max(0, Math.min(maxOff, Math.round(next))));
+ }
- _totalTicks() { return this.ppq * this.timeSigNum * this.bars; }
+ _totalTicks() {
+ return this.ppq * this.timeSigNum * this.bars;
+ }
- // -----------------------------------------------------------------
- // Draw
- // -----------------------------------------------------------------
+ // -----------------------------------------------------------------
+ // Draw
+ // -----------------------------------------------------------------
- draw() {
- this._dirty = false;
- const canvas = this.canvas;
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
+ draw() {
+ this._dirty = false;
+ const canvas = this.canvas;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
- const w = canvas.clientWidth || canvas.offsetWidth || 0;
- const h = canvas.clientHeight || canvas.offsetHeight || 0;
- if (w <= 0 || h <= 0) return;
+ const w = canvas.clientWidth || canvas.offsetWidth || 0;
+ const h = canvas.clientHeight || canvas.offsetHeight || 0;
+ if (w <= 0 || h <= 0) return;
- // DPR-aware canvas sizing (fixes rendering on retina screens)
- const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
- const wantW = Math.round(w * dpr);
- const wantH = Math.round(h * dpr);
- if (canvas.width !== wantW || canvas.height !== wantH) {
- canvas.width = wantW;
- canvas.height = wantH;
- }
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ // DPR-aware canvas sizing (fixes rendering on retina screens)
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
+ const wantW = Math.round(w * dpr);
+ const wantH = Math.round(h * dpr);
+ if (canvas.width !== wantW || canvas.height !== wantH) {
+ canvas.width = wantW;
+ canvas.height = wantH;
+ }
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- const total = this._totalTicks();
- const dark = typeof document !== 'undefined'
- && document.body.classList.contains('theme-dark');
+ const total = this._totalTicks();
+ const dark =
+ typeof document !== 'undefined' && document.body.classList.contains('theme-dark');
- ctx.fillStyle = dark ? '#0f0f22' : '#eef0f4';
- ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = dark ? '#0f0f22' : '#eef0f4';
+ ctx.fillRect(0, 0, w, h);
- this._drawGrid(ctx, w, h, total, dark);
- this._drawNotes(ctx, w, h, total, dark);
- this._drawViewport(ctx, w, h, total);
- this._drawPlayhead(ctx, w, h, total);
- }
+ this._drawGrid(ctx, w, h, total, dark);
+ this._drawNotes(ctx, w, h, total, dark);
+ this._drawViewport(ctx, w, h, total);
+ this._drawPlayhead(ctx, w, h, total);
+ }
- _drawGrid(ctx, w, h, total, dark) {
- const ticksPerBeat = this.ppq;
- const ticksPerBar = ticksPerBeat * this.timeSigNum;
- ctx.lineWidth = 1;
- for (let t = 0; t < total; t += ticksPerBeat) {
- const x = (t / total) * w;
- const isBar = (t % ticksPerBar) === 0;
- ctx.strokeStyle = isBar
- ? (dark ? 'rgba(150,150,220,0.35)' : 'rgba(100,110,140,0.3)')
- : (dark ? 'rgba(100,100,180,0.12)' : 'rgba(160,170,200,0.15)');
- ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
- }
- ctx.fillStyle = dark ? 'rgba(180,180,220,0.5)' : 'rgba(100,110,140,0.55)';
- ctx.font = `${Math.min(10, h * 0.27)}px sans-serif`;
- ctx.textBaseline = 'top';
- for (let b = 0; b < this.bars; b++) {
- const x = (b * ticksPerBar / total) * w;
- ctx.fillText(b + 1, x + 2, 2);
- }
- }
+ _drawGrid(ctx, w, h, total, dark) {
+ const ticksPerBeat = this.ppq;
+ const ticksPerBar = ticksPerBeat * this.timeSigNum;
+ ctx.lineWidth = 1;
+ for (let t = 0; t < total; t += ticksPerBeat) {
+ const x = (t / total) * w;
+ const isBar = t % ticksPerBar === 0;
+ ctx.strokeStyle = isBar
+ ? dark
+ ? 'rgba(150,150,220,0.35)'
+ : 'rgba(100,110,140,0.3)'
+ : dark
+ ? 'rgba(100,100,180,0.12)'
+ : 'rgba(160,170,200,0.15)';
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, h);
+ ctx.stroke();
+ }
+ ctx.fillStyle = dark ? 'rgba(180,180,220,0.5)' : 'rgba(100,110,140,0.55)';
+ ctx.font = `${Math.min(10, h * 0.27)}px sans-serif`;
+ ctx.textBaseline = 'top';
+ for (let b = 0; b < this.bars; b++) {
+ const x = ((b * ticksPerBar) / total) * w;
+ ctx.fillText(b + 1, x + 2, 2);
+ }
+ }
- _drawNotes(ctx, w, h, total, dark) {
- const noteSpan = Math.max(this.noteMax - this.noteMin, 1);
- const noteH = Math.max(2, h * 0.08);
- const noteArea = h * 0.78;
- ctx.fillStyle = dark ? '#5b9bd5' : '#4a90d9';
- for (const note of this._notes) {
- const x = (note.t / total) * w;
- const nw = Math.max(2, ((note.g || note.l || 120) / total) * w);
- const ny = h * 0.18 + noteArea - ((note.n - this.noteMin) / noteSpan) * noteArea;
- ctx.fillRect(x, ny - noteH, nw, noteH);
- }
- }
+ _drawNotes(ctx, w, h, total, dark) {
+ const noteSpan = Math.max(this.noteMax - this.noteMin, 1);
+ const noteH = Math.max(2, h * 0.08);
+ const noteArea = h * 0.78;
+ ctx.fillStyle = dark ? '#5b9bd5' : '#4a90d9';
+ for (const note of this._notes) {
+ const x = (note.t / total) * w;
+ const nw = Math.max(2, ((note.g || note.l || 120) / total) * w);
+ const ny = h * 0.18 + noteArea - ((note.n - this.noteMin) / noteSpan) * noteArea;
+ ctx.fillRect(x, ny - noteH, nw, noteH);
+ }
+ }
- _drawViewport(ctx, w, h, total) {
- const xrange = this._xrange || total;
- const vx = (this._xoffset / total) * w;
- const vw = Math.min(w, (xrange / total) * w);
- ctx.fillStyle = 'rgba(74,144,217,0.12)';
- ctx.fillRect(vx, 0, vw, h);
- ctx.strokeStyle = 'rgba(74,144,217,0.7)';
- ctx.lineWidth = 1.5;
- ctx.strokeRect(vx + 0.5, 0.5, Math.max(2, vw - 1), h - 1);
- }
+ _drawViewport(ctx, w, h, total) {
+ const xrange = this._xrange || total;
+ const vx = (this._xoffset / total) * w;
+ const vw = Math.min(w, (xrange / total) * w);
+ ctx.fillStyle = 'rgba(74,144,217,0.12)';
+ ctx.fillRect(vx, 0, vw, h);
+ ctx.strokeStyle = 'rgba(74,144,217,0.7)';
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(vx + 0.5, 0.5, Math.max(2, vw - 1), h - 1);
+ }
- _drawPlayhead(ctx, w, h, total) {
- if (this._cursor <= 0 && !this._isPlaying) return;
- const px = (this._cursor / total) * w;
- ctx.strokeStyle = '#e74c3c';
- ctx.lineWidth = 1.5;
- ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke();
- }
+ _drawPlayhead(ctx, w, h, total) {
+ if (this._cursor <= 0 && !this._isPlaying) return;
+ const px = (this._cursor / total) * w;
+ ctx.strokeStyle = '#e74c3c';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(px, 0);
+ ctx.lineTo(px, h);
+ ctx.stroke();
+ }
- // -----------------------------------------------------------------
- // Lifecycle
- // -----------------------------------------------------------------
+ // -----------------------------------------------------------------
+ // Lifecycle
+ // -----------------------------------------------------------------
- destroy() {
- if (this._rafHandle) {
- cancelAnimationFrame(this._rafHandle);
- this._rafHandle = null;
- }
- const c = this.canvas;
- if (c?.removeEventListener) {
- c.removeEventListener('mousedown', this._onMouseDown);
- c.removeEventListener('mousemove', this._onMouseMove);
- c.removeEventListener('mouseup', this._onMouseUp);
- c.removeEventListener('mouseleave', this._onMouseLeave);
- c.removeEventListener('touchstart', this._onTouchStart);
- c.removeEventListener('touchmove', this._onTouchMove);
- c.removeEventListener('touchend', this._onTouchEnd);
- c.removeEventListener('keydown', this._onKeyDown);
- }
- this._notes = [];
- this.canvas = null;
- this.onSeek = null;
- }
+ destroy() {
+ if (this._rafHandle) {
+ cancelAnimationFrame(this._rafHandle);
+ this._rafHandle = null;
+ }
+ const c = this.canvas;
+ if (c?.removeEventListener) {
+ c.removeEventListener('mousedown', this._onMouseDown);
+ c.removeEventListener('mousemove', this._onMouseMove);
+ c.removeEventListener('mouseup', this._onMouseUp);
+ c.removeEventListener('mouseleave', this._onMouseLeave);
+ c.removeEventListener('touchstart', this._onTouchStart);
+ c.removeEventListener('touchmove', this._onTouchMove);
+ c.removeEventListener('touchend', this._onTouchEnd);
+ c.removeEventListener('keydown', this._onKeyDown);
+ }
+ this._notes = [];
+ this.canvas = null;
+ this.onSeek = null;
}
+ }
- if (typeof window !== 'undefined') {
- window.LoopCreatorMinimap = LoopCreatorMinimap;
- }
- if (typeof module !== 'undefined' && module.exports) {
- module.exports = LoopCreatorMinimap;
- }
+ if (typeof window !== 'undefined') {
+ window.LoopCreatorMinimap = LoopCreatorMinimap;
+ }
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = LoopCreatorMinimap;
+ }
})();
diff --git a/public/js/features/LoopCreatorModal.js b/public/js/features/LoopCreatorModal.js
index 0517daf47..97d54f2aa 100644
--- a/public/js/features/LoopCreatorModal.js
+++ b/public/js/features/LoopCreatorModal.js
@@ -13,475 +13,841 @@
// ── GM program data ───────────────────────────────────────────
const GM_PROGRAM_NAMES = [
- 'Acoustic Grand Piano','Bright Acoustic Piano','Electric Grand Piano','Honky-tonk Piano',
- 'Electric Piano 1','Electric Piano 2','Harpsichord','Clavinet',
- 'Celesta','Glockenspiel','Music Box','Vibraphone','Marimba','Xylophone','Tubular Bells','Dulcimer',
- 'Drawbar Organ','Percussive Organ','Rock Organ','Church Organ','Reed Organ','Accordion','Harmonica','Tango Accordion',
- 'Acoustic Guitar (nylon)','Acoustic Guitar (steel)','Electric Guitar (jazz)','Electric Guitar (clean)',
- 'Electric Guitar (muted)','Overdriven Guitar','Distortion Guitar','Guitar Harmonics',
- 'Acoustic Bass','Electric Bass (finger)','Electric Bass (pick)','Fretless Bass',
- 'Slap Bass 1','Slap Bass 2','Synth Bass 1','Synth Bass 2',
- 'Violin','Viola','Cello','Contrabass','Tremolo Strings','Pizzicato Strings','Orchestral Harp','Timpani',
- 'String Ensemble 1','String Ensemble 2','Synth Strings 1','Synth Strings 2',
- 'Choir Aahs','Voice Oohs','Synth Voice','Orchestra Hit',
- 'Trumpet','Trombone','Tuba','Muted Trumpet','French Horn','Brass Section','Synth Brass 1','Synth Brass 2',
- 'Soprano Sax','Alto Sax','Tenor Sax','Baritone Sax',
- 'Oboe','English Horn','Bassoon','Clarinet',
- 'Piccolo','Flute','Recorder','Pan Flute','Blown Bottle','Shakuhachi','Whistle','Ocarina',
- 'Lead 1 (square)','Lead 2 (sawtooth)','Lead 3 (calliope)','Lead 4 (chiff)',
- 'Lead 5 (charang)','Lead 6 (voice)','Lead 7 (fifths)','Lead 8 (bass + lead)',
- 'Pad 1 (new age)','Pad 2 (warm)','Pad 3 (polysynth)','Pad 4 (choir)',
- 'Pad 5 (bowed)','Pad 6 (metallic)','Pad 7 (halo)','Pad 8 (sweep)',
- 'FX 1 (rain)','FX 2 (soundtrack)','FX 3 (crystal)','FX 4 (atmosphere)',
- 'FX 5 (brightness)','FX 6 (goblins)','FX 7 (echoes)','FX 8 (sci-fi)',
- 'Sitar','Banjo','Shamisen','Koto','Kalimba','Bagpipe','Fiddle','Shanai',
- 'Tinkle Bell','Agogo','Steel Drums','Woodblock','Taiko Drum','Melodic Tom','Synth Drum','Reverse Cymbal',
- 'Guitar Fret Noise','Breath Noise','Seashore','Bird Tweet','Telephone Ring','Helicopter','Applause','Gunshot'
+ 'Acoustic Grand Piano',
+ 'Bright Acoustic Piano',
+ 'Electric Grand Piano',
+ 'Honky-tonk Piano',
+ 'Electric Piano 1',
+ 'Electric Piano 2',
+ 'Harpsichord',
+ 'Clavinet',
+ 'Celesta',
+ 'Glockenspiel',
+ 'Music Box',
+ 'Vibraphone',
+ 'Marimba',
+ 'Xylophone',
+ 'Tubular Bells',
+ 'Dulcimer',
+ 'Drawbar Organ',
+ 'Percussive Organ',
+ 'Rock Organ',
+ 'Church Organ',
+ 'Reed Organ',
+ 'Accordion',
+ 'Harmonica',
+ 'Tango Accordion',
+ 'Acoustic Guitar (nylon)',
+ 'Acoustic Guitar (steel)',
+ 'Electric Guitar (jazz)',
+ 'Electric Guitar (clean)',
+ 'Electric Guitar (muted)',
+ 'Overdriven Guitar',
+ 'Distortion Guitar',
+ 'Guitar Harmonics',
+ 'Acoustic Bass',
+ 'Electric Bass (finger)',
+ 'Electric Bass (pick)',
+ 'Fretless Bass',
+ 'Slap Bass 1',
+ 'Slap Bass 2',
+ 'Synth Bass 1',
+ 'Synth Bass 2',
+ 'Violin',
+ 'Viola',
+ 'Cello',
+ 'Contrabass',
+ 'Tremolo Strings',
+ 'Pizzicato Strings',
+ 'Orchestral Harp',
+ 'Timpani',
+ 'String Ensemble 1',
+ 'String Ensemble 2',
+ 'Synth Strings 1',
+ 'Synth Strings 2',
+ 'Choir Aahs',
+ 'Voice Oohs',
+ 'Synth Voice',
+ 'Orchestra Hit',
+ 'Trumpet',
+ 'Trombone',
+ 'Tuba',
+ 'Muted Trumpet',
+ 'French Horn',
+ 'Brass Section',
+ 'Synth Brass 1',
+ 'Synth Brass 2',
+ 'Soprano Sax',
+ 'Alto Sax',
+ 'Tenor Sax',
+ 'Baritone Sax',
+ 'Oboe',
+ 'English Horn',
+ 'Bassoon',
+ 'Clarinet',
+ 'Piccolo',
+ 'Flute',
+ 'Recorder',
+ 'Pan Flute',
+ 'Blown Bottle',
+ 'Shakuhachi',
+ 'Whistle',
+ 'Ocarina',
+ 'Lead 1 (square)',
+ 'Lead 2 (sawtooth)',
+ 'Lead 3 (calliope)',
+ 'Lead 4 (chiff)',
+ 'Lead 5 (charang)',
+ 'Lead 6 (voice)',
+ 'Lead 7 (fifths)',
+ 'Lead 8 (bass + lead)',
+ 'Pad 1 (new age)',
+ 'Pad 2 (warm)',
+ 'Pad 3 (polysynth)',
+ 'Pad 4 (choir)',
+ 'Pad 5 (bowed)',
+ 'Pad 6 (metallic)',
+ 'Pad 7 (halo)',
+ 'Pad 8 (sweep)',
+ 'FX 1 (rain)',
+ 'FX 2 (soundtrack)',
+ 'FX 3 (crystal)',
+ 'FX 4 (atmosphere)',
+ 'FX 5 (brightness)',
+ 'FX 6 (goblins)',
+ 'FX 7 (echoes)',
+ 'FX 8 (sci-fi)',
+ 'Sitar',
+ 'Banjo',
+ 'Shamisen',
+ 'Koto',
+ 'Kalimba',
+ 'Bagpipe',
+ 'Fiddle',
+ 'Shanai',
+ 'Tinkle Bell',
+ 'Agogo',
+ 'Steel Drums',
+ 'Woodblock',
+ 'Taiko Drum',
+ 'Melodic Tom',
+ 'Synth Drum',
+ 'Reverse Cymbal',
+ 'Guitar Fret Noise',
+ 'Breath Noise',
+ 'Seashore',
+ 'Bird Tweet',
+ 'Telephone Ring',
+ 'Helicopter',
+ 'Applause',
+ 'Gunshot'
];
-const GM_FAMILIES = (typeof window !== 'undefined' && window.LoopUtils && window.LoopUtils.GM_FAMILIES) || [];
+const GM_FAMILIES =
+ (typeof window !== 'undefined' && window.LoopUtils && window.LoopUtils.GM_FAMILIES) || [];
// ARRANGER_HISTORY_LIMIT moved to LoopManagerArrangerFeature (audit §6.6).
class LoopManagerModal extends BaseModal {
- /** Back-compat: feature owns the loop array; modal exposes it readonly. */
- get library() { return this.libraryFeature?.library || []; }
- /** Back-compat: Library cards read this Map to display play indicator. */
- get _livePlayingLoops() { return this.liveFeature?.playingLoops || new Map(); }
- /** Back-compat: a few call-sites read modal._liveSynth directly. */
- get _liveSynth() { return this.liveFeature?.synth || null; }
- /** Back-compat: Library cards / _deleteLoopById read this array. */
- get _padSlots() { return this.padFeature?.slots || []; }
- /** Back-compat: _switchTab + _onClick read this flag. */
- get _padPickerIndex() { return this.padFeature?.pickerIndex ?? null; }
- /** Back-compat: _renderPlaybar iterates Pad start/dur per index. */
- get _padPlayTimes() { return this.padFeature?.playTimes || new Map(); }
- /** Back-compat: doClose calls this if armed. */
- get _padClearLongPress() { return this.padFeature ? () => this.padFeature.clearLongPress() : null; }
- /** Back-compat: _renderPlaybar inspects which pads are currently playing. */
- get _padPlayingIndex() { return this.padFeature?.playingIndex || new Set(); }
- /** Back-compat: every feature reads `modal._globalOutput`. */
- get _globalOutput() { return this.outputRouter?.globalOutput || { mode: 'synth', deviceId: null, channel: 0 }; }
- /** Back-compat: Live/Pad stop-all reads `_deviceShim` for cancelAllNotes. */
- get _deviceShim() { return this.outputRouter?.deviceShim || null; }
- set _deviceShim(v) { if (this.outputRouter) this.outputRouter.deviceShim = v; }
- /** Back-compat: cached device list. */
- get _cachedDevices() { return this.outputRouter?.cachedDevices || []; }
-
- constructor(api, eventBus) {
- super({
- id: 'loop-manager-modal',
- size: 'full',
- title: 'loopManager.title',
- closeOnOverlay: false,
- customClass: 'loop-creator-modal'
- });
- this.api = api;
- this.eventBus = eventBus || window.eventBus || null;
-
- this.activeTab = 'library';
-
- // ── Library state ──
- // ── Library tab feature (extracted to LoopManagerLibraryFeature
- // per audit §6.6). Owns `library` array + search/filter/sort state.
- // The modal exposes `this.library` as a getter for back-compat with
- // the other features that read it directly (Pad, Live, Arranger).
- this.libraryFeature = typeof LoopManagerLibraryFeature !== 'undefined'
- ? new LoopManagerLibraryFeature(this, {
- onDeleteLoop: (id) => this._deleteLoopById(id),
- onOpenLoopEditor: (id) => this._loopEditor.open({ loopId: id }),
- onLibraryLoaded: () => {
- if (this.activeTab === 'live') this._renderLiveArea();
- this._renderPalette();
- }
- })
- : null;
-
- // ── Arranger state ──
- this.currentArrangementId = null;
- this.arrangementName = '';
- this.arrangementTempo = 120;
- this.arrangementBars = 16;
- this.tracks = [];
- this.blocks = [];
- this._arrangerZoom = 1; // horizontal zoom factor for the timeline
- this.isArrangerPlaying = false;
- this._arrangerTimers = [];
- this._arrangerSynth = null;
- this._dragInfo = null;
- this._dropPreview = null;
-
- // Arranger undo/redo (local to current arrangement)
- this._arrHistory = [];
- this._arrHistoryIdx = -1;
- this._arrDirty = false; // explicit init (pre-refactor relied on undefined→falsy)
- this._selectedBlocks = new Set();
-
- // Arranger UX state
- this._trackMute = new Set(); // track ids muted (local, not persisted)
- this._trackSolo = new Set(); // track ids soloed
- this._blockClipboard = []; // copied blocks (relative positions)
- this._resizeState = null; // active block resize state
- this._autoSaveTimer = null; // debounced metadata save
- this._blockMenuEl = null; // open context menu element
- this._paletteSearch = ''; // palette filter text
- this._arrangerZoomV = 1; // vertical zoom (track height multiplier)
- this._arrangerLoop = false; // loop arrangement playback
- this._arrangerCountIn = false; // play 1-bar metronome before start
- this._arrangerStartBar = 0; // bar offset where playback begins
- this._trackDragId = null; // track currently being reordered (visual only)
-
- // Shared loop data cache (pad + live + arranger)
- this._fetchLoopDataCache = new Map();
-
- // ── Pad tab feature (extracted to LoopManagerPadFeature per audit §6.6).
- // Owns the grid layout, slots, play mode, quantize, synth, picker.
- this.padFeature = typeof LoopManagerPadFeature !== 'undefined'
- ? new LoopManagerPadFeature(this)
- : null;
-
- // ── Arranger tab feature (extracted to LoopManagerArrangerFeature
- // per audit §6.6). State stays on the modal (currentArrangementId,
- // tracks, blocks, _arr*, etc.) — the feature holds the methods.
- this.arrangerFeature = typeof LoopManagerArrangerFeature !== 'undefined'
- ? new LoopManagerArrangerFeature(this)
- : null;
-
- // ── View (HTML rendering) and Events sub-features (audit §1.3).
- this.view = typeof LoopCreatorModalView !== 'undefined'
- ? new LoopCreatorModalView(this)
- : null;
- this.events = typeof LoopCreatorModalEvents !== 'undefined'
- ? new LoopCreatorModalEvents(this)
- : null;
-
- // ── Live state ──
- // ── Live tab feature (extracted to LoopManagerLiveFeature
- // per audit §6.6). Owns playingLoops Map, synth, search.
- this.liveFeature = typeof LoopManagerLiveFeature !== 'undefined'
- ? new LoopManagerLiveFeature(this)
- : null;
-
- // ── Keyboard tab feature (extracted to LoopManagerKeyboardFeature
- // per audit §6.6). Owns its own state (synth, mounted, envelopes,
- // activeKeys, instrument, isDrum). Public API used by this modal:
- // keyboard.enterTab(), keyboard.unmount(), keyboard.stopAllNotes(),
- // keyboard.mounted.
- this.keyboard = typeof LoopManagerKeyboardFeature !== 'undefined'
- ? new LoopManagerKeyboardFeature(this)
- : null;
-
- // ── Playback timeline bar ──
- // (_padPlayTimes lives on padFeature.playTimes; getter below.)
- this._arrangerStartTime = 0;
- this._playbarRAF = null;
-
- // Arranger output (synth by default)
- this.outputMode = 'synth';
- this.outputDeviceId = null;
- this.outputChannel = 0;
-
- // ── Global output selector (header) — drives Pad / Live / Arranger ──
- // Extracted to LoopManagerOutputRouter (audit §6.6). State lives
- // on the router; the modal exposes getters for back-compat.
- this.outputRouter = typeof LoopManagerOutputRouter !== 'undefined'
- ? new LoopManagerOutputRouter(this)
- : null;
-
- // Bound doc handlers for arranger drag
- this._boundDocMouseUp = this._onDocMouseUp.bind(this);
- this._boundDocMouseMove = this._onDocMouseMove.bind(this);
- this._boundKeyDown = this._onKeyDown.bind(this);
-
- // Loop editor — created once, shared across sessions
- this._loopEditor = new LoopEditorModal(api, eventBus, {
- onSaved: (loopId) => this._onLoopSaved(loopId)
- });
- }
-
- // =========================================================
- // RENDERING — SHELL
- // =========================================================
-
- // Delegates to view sub-feature (extracted per audit §1.3)
- _renderHeader() { return this.view?._renderHeader() ?? ''; }
- renderBody() { return this.view?.renderBody() ?? ''; }
- _renderKeyboardTab() { return this.view?._renderKeyboardTab() ?? ''; }
- renderFooter() { return this.view?.renderFooter() ?? ''; }
- _renderLibraryTab() { return this.view?._renderLibraryTab() ?? ''; }
- _renderPadTab() { return this.view?._renderPadTab() ?? ''; }
- _renderLiveTab() { return this.view?._renderLiveTab() ?? ''; }
- _renderArrangerTab() { return this.view?._renderArrangerTab() ?? ''; }
-
- // =========================================================
- // LIFECYCLE
- // =========================================================
-
- onOpen() {
- // _createDOM has already rendered the body with constructor defaults.
- // Load persisted layout, then push the loaded values back onto the bar
- // (cols/rows inputs + mode/quantize buttons) so the UI matches state.
- this._loadPadLayout();
- this._syncPadControls();
- this._initArrangerSynth();
- this._initPadSynth();
- this._initLiveSynth();
- this._attachEvents();
- this._loadLibrary();
- this._loadHeaderOutputDevices();
- document.addEventListener('mouseup', this._boundDocMouseUp);
- document.addEventListener('mousemove', this._boundDocMouseMove);
- document.addEventListener('keydown', this._boundKeyDown);
-
- // Run the per-tab init that `_switchTab` would normally trigger.
- // Without this, opening on a non-library tab (or even library if
- // the async library load hasn't fired yet) leaves the pane
- // visually selected but unpopulated until the user manually
- // clicks another tab and back.
- this._initActiveTab();
- }
-
- /**
- * Initial-render hook : run the same per-tab init that `_switchTab`
- * performs, but for the currently active tab. Idempotent — every
- * `_render*` / `_init*` it calls is safe to invoke twice.
- */
- _initActiveTab() {
- switch (this.activeTab) {
- case 'library': this._filterAndRenderLibrary(); break;
- case 'pad': this._renderPadGrid(); break;
- case 'live': this._renderLiveArea(); break;
- case 'keyboard': this.keyboard?.enterTab(); break;
- case 'arranger': this._initArrangerTab(); break;
- }
- }
-
- // ── Global output selector (header) ────────────────────────
- async _loadHeaderOutputDevices() { return this.outputRouter?.loadDevices(); }
- _refreshHeaderOutputUI() { this.outputRouter?.refreshUI(); }
- _toggleHeaderOutput() { this.outputRouter?.toggleMode(); }
- _setGlobalOutput(next) { this.outputRouter?.setOutput(next); }
- _panicCurrentDevice(target) { this.outputRouter?.panicTarget(target); }
- _getOutputTarget(fallbackSynth) { return this.outputRouter?.getTarget(fallbackSynth) ?? fallbackSynth; }
-
-
- close() {
- if (!this._arrDirty) {
- super.close();
- return;
- }
- // Confirmation accessible (AUDIT §A1).
- const doSuperClose = () => super.close();
- LoopUtils.confirm(this.t('loopManager.confirmDiscardChanges'), {
- icon: '⚠️',
- danger: true
- }).then((ok) => { if (ok) doSuperClose(); });
- }
-
- onClose() {
- if (this._autoSaveTimer) { clearTimeout(this._autoSaveTimer); this._autoSaveTimer = null; }
- this._closeBlockMenu();
- this._stopAllPads();
- this._liveStopAll();
- this._stopArrangerPlay();
- this._stopPlaybarRAF();
- this._closePadPicker();
- this.keyboard?.stopAllNotes();
- if (this.keyboard?.mounted) this.keyboard.unmount();
- // Coupe les timers/UI résiduels du Pad et de l'Arranger (AUDIT §L9, §L10).
- if (typeof this._padClearLongPress === 'function') this._padClearLongPress();
- this._hideDropPreview();
- document.removeEventListener('mouseup', this._boundDocMouseUp);
- document.removeEventListener('mousemove', this._boundDocMouseMove);
- document.removeEventListener('keydown', this._boundKeyDown);
- }
-
- // =========================================================
- // KEYBOARD SHORTCUTS
- // =========================================================
-
- // Delegates to events sub-feature (extracted per audit §1.3)
- _onKeyDown(e) { return this.events?._onKeyDown(e); }
- _switchTab(tab) { return this.events?._switchTab(tab); }
- _attachEvents() { return this.events?._attachEvents(); }
- _onClick(e) { return this.events?._onClick(e); }
- _onContextMenu(e) { return this.events?._onContextMenu(e); }
- _openBlockMenu(x, y) { return this.events?._openBlockMenu(x, y); }
- _closeBlockMenu() { return this.events?._closeBlockMenu(); }
- _onChange(e) { return this.events?._onChange(e); }
- _onInput(e) { return this.events?._onInput(e); }
-
- // =========================================================
- // LIBRARY
- // =========================================================
-
- async _loadLibrary() {
- return this.libraryFeature?.loadLibrary();
+ /** Back-compat: feature owns the loop array; modal exposes it readonly. */
+ get library() {
+ return this.libraryFeature?.library || [];
+ }
+ /** Back-compat: Library cards read this Map to display play indicator. */
+ get _livePlayingLoops() {
+ return this.liveFeature?.playingLoops || new Map();
+ }
+ /** Back-compat: a few call-sites read modal._liveSynth directly. */
+ get _liveSynth() {
+ return this.liveFeature?.synth || null;
+ }
+ /** Back-compat: Library cards / _deleteLoopById read this array. */
+ get _padSlots() {
+ return this.padFeature?.slots || [];
+ }
+ /** Back-compat: _switchTab + _onClick read this flag. */
+ get _padPickerIndex() {
+ return this.padFeature?.pickerIndex ?? null;
+ }
+ /** Back-compat: _renderPlaybar iterates Pad start/dur per index. */
+ get _padPlayTimes() {
+ return this.padFeature?.playTimes || new Map();
+ }
+ /** Back-compat: doClose calls this if armed. */
+ get _padClearLongPress() {
+ return this.padFeature ? () => this.padFeature.clearLongPress() : null;
+ }
+ /** Back-compat: _renderPlaybar inspects which pads are currently playing. */
+ get _padPlayingIndex() {
+ return this.padFeature?.playingIndex || new Set();
+ }
+ /** Back-compat: every feature reads `modal._globalOutput`. */
+ get _globalOutput() {
+ return this.outputRouter?.globalOutput || { mode: 'synth', deviceId: null, channel: 0 };
+ }
+ /** Back-compat: Live/Pad stop-all reads `_deviceShim` for cancelAllNotes. */
+ get _deviceShim() {
+ return this.outputRouter?.deviceShim || null;
+ }
+ set _deviceShim(v) {
+ if (this.outputRouter) this.outputRouter.deviceShim = v;
+ }
+ /** Back-compat: cached device list. */
+ get _cachedDevices() {
+ return this.outputRouter?.cachedDevices || [];
+ }
+
+ constructor(api, eventBus) {
+ super({
+ id: 'loop-manager-modal',
+ size: 'full',
+ title: 'loopManager.title',
+ closeOnOverlay: false,
+ customClass: 'loop-creator-modal'
+ });
+ this.api = api;
+ this.eventBus = eventBus || window.eventBus || null;
+
+ this.activeTab = 'library';
+
+ // ── Library state ──
+ // ── Library tab feature (extracted to LoopManagerLibraryFeature
+ // per audit §6.6). Owns `library` array + search/filter/sort state.
+ // The modal exposes `this.library` as a getter for back-compat with
+ // the other features that read it directly (Pad, Live, Arranger).
+ this.libraryFeature =
+ typeof LoopManagerLibraryFeature !== 'undefined'
+ ? new LoopManagerLibraryFeature(this, {
+ onDeleteLoop: (id) => this._deleteLoopById(id),
+ onOpenLoopEditor: (id) => this._loopEditor.open({ loopId: id }),
+ onLibraryLoaded: () => {
+ if (this.activeTab === 'live') this._renderLiveArea();
+ this._renderPalette();
+ }
+ })
+ : null;
+
+ // ── Arranger state ──
+ this.currentArrangementId = null;
+ this.arrangementName = '';
+ this.arrangementTempo = 120;
+ this.arrangementBars = 16;
+ this.tracks = [];
+ this.blocks = [];
+ this._arrangerZoom = 1; // horizontal zoom factor for the timeline
+ this.isArrangerPlaying = false;
+ this._arrangerTimers = [];
+ this._arrangerSynth = null;
+ this._dragInfo = null;
+ this._dropPreview = null;
+
+ // Arranger undo/redo (local to current arrangement)
+ this._arrHistory = [];
+ this._arrHistoryIdx = -1;
+ this._arrDirty = false; // explicit init (pre-refactor relied on undefined→falsy)
+ this._selectedBlocks = new Set();
+
+ // Arranger UX state
+ this._trackMute = new Set(); // track ids muted (local, not persisted)
+ this._trackSolo = new Set(); // track ids soloed
+ this._blockClipboard = []; // copied blocks (relative positions)
+ this._resizeState = null; // active block resize state
+ this._autoSaveTimer = null; // debounced metadata save
+ this._blockMenuEl = null; // open context menu element
+ this._paletteSearch = ''; // palette filter text
+ this._arrangerZoomV = 1; // vertical zoom (track height multiplier)
+ this._arrangerLoop = false; // loop arrangement playback
+ this._arrangerCountIn = false; // play 1-bar metronome before start
+ this._arrangerStartBar = 0; // bar offset where playback begins
+ this._trackDragId = null; // track currently being reordered (visual only)
+
+ // Shared loop data cache (pad + live + arranger)
+ this._fetchLoopDataCache = new Map();
+
+ // ── Pad tab feature (extracted to LoopManagerPadFeature per audit §6.6).
+ // Owns the grid layout, slots, play mode, quantize, synth, picker.
+ this.padFeature =
+ typeof LoopManagerPadFeature !== 'undefined' ? new LoopManagerPadFeature(this) : null;
+
+ // ── Arranger tab feature (extracted to LoopManagerArrangerFeature
+ // per audit §6.6). State stays on the modal (currentArrangementId,
+ // tracks, blocks, _arr*, etc.) — the feature holds the methods.
+ this.arrangerFeature =
+ typeof LoopManagerArrangerFeature !== 'undefined'
+ ? new LoopManagerArrangerFeature(this)
+ : null;
+
+ // ── View (HTML rendering) and Events sub-features (audit §1.3).
+ this.view = typeof LoopCreatorModalView !== 'undefined' ? new LoopCreatorModalView(this) : null;
+ this.events =
+ typeof LoopCreatorModalEvents !== 'undefined' ? new LoopCreatorModalEvents(this) : null;
+
+ // ── Live state ──
+ // ── Live tab feature (extracted to LoopManagerLiveFeature
+ // per audit §6.6). Owns playingLoops Map, synth, search.
+ this.liveFeature =
+ typeof LoopManagerLiveFeature !== 'undefined' ? new LoopManagerLiveFeature(this) : null;
+
+ // ── Keyboard tab feature (extracted to LoopManagerKeyboardFeature
+ // per audit §6.6). Owns its own state (synth, mounted, envelopes,
+ // activeKeys, instrument, isDrum). Public API used by this modal:
+ // keyboard.enterTab(), keyboard.unmount(), keyboard.stopAllNotes(),
+ // keyboard.mounted.
+ this.keyboard =
+ typeof LoopManagerKeyboardFeature !== 'undefined'
+ ? new LoopManagerKeyboardFeature(this)
+ : null;
+
+ // ── Playback timeline bar ──
+ // (_padPlayTimes lives on padFeature.playTimes; getter below.)
+ this._arrangerStartTime = 0;
+ this._playbarRAF = null;
+
+ // Arranger output (synth by default)
+ this.outputMode = 'synth';
+ this.outputDeviceId = null;
+ this.outputChannel = 0;
+
+ // ── Global output selector (header) — drives Pad / Live / Arranger ──
+ // Extracted to LoopManagerOutputRouter (audit §6.6). State lives
+ // on the router; the modal exposes getters for back-compat.
+ this.outputRouter =
+ typeof LoopManagerOutputRouter !== 'undefined' ? new LoopManagerOutputRouter(this) : null;
+
+ // Bound doc handlers for arranger drag
+ this._boundDocMouseUp = this._onDocMouseUp.bind(this);
+ this._boundDocMouseMove = this._onDocMouseMove.bind(this);
+ this._boundKeyDown = this._onKeyDown.bind(this);
+
+ // Loop editor — created once, shared across sessions
+ this._loopEditor = new LoopEditorModal(api, eventBus, {
+ onSaved: (loopId) => this._onLoopSaved(loopId)
+ });
+ }
+
+ // =========================================================
+ // RENDERING — SHELL
+ // =========================================================
+
+ // Delegates to view sub-feature (extracted per audit §1.3)
+ _renderHeader() {
+ return this.view?._renderHeader() ?? '';
+ }
+ renderBody() {
+ return this.view?.renderBody() ?? '';
+ }
+ _renderKeyboardTab() {
+ return this.view?._renderKeyboardTab() ?? '';
+ }
+ renderFooter() {
+ return this.view?.renderFooter() ?? '';
+ }
+ _renderLibraryTab() {
+ return this.view?._renderLibraryTab() ?? '';
+ }
+ _renderPadTab() {
+ return this.view?._renderPadTab() ?? '';
+ }
+ _renderLiveTab() {
+ return this.view?._renderLiveTab() ?? '';
+ }
+ _renderArrangerTab() {
+ return this.view?._renderArrangerTab() ?? '';
+ }
+
+ // =========================================================
+ // LIFECYCLE
+ // =========================================================
+
+ onOpen() {
+ // _createDOM has already rendered the body with constructor defaults.
+ // Load persisted layout, then push the loaded values back onto the bar
+ // (cols/rows inputs + mode/quantize buttons) so the UI matches state.
+ this._loadPadLayout();
+ this._syncPadControls();
+ this._initArrangerSynth();
+ this._initPadSynth();
+ this._initLiveSynth();
+ this._attachEvents();
+ this._loadLibrary();
+ this._loadHeaderOutputDevices();
+ document.addEventListener('mouseup', this._boundDocMouseUp);
+ document.addEventListener('mousemove', this._boundDocMouseMove);
+ document.addEventListener('keydown', this._boundKeyDown);
+
+ // Run the per-tab init that `_switchTab` would normally trigger.
+ // Without this, opening on a non-library tab (or even library if
+ // the async library load hasn't fired yet) leaves the pane
+ // visually selected but unpopulated until the user manually
+ // clicks another tab and back.
+ this._initActiveTab();
+ }
+
+ /**
+ * Initial-render hook : run the same per-tab init that `_switchTab`
+ * performs, but for the currently active tab. Idempotent — every
+ * `_render*` / `_init*` it calls is safe to invoke twice.
+ */
+ _initActiveTab() {
+ switch (this.activeTab) {
+ case 'library':
+ this._filterAndRenderLibrary();
+ break;
+ case 'pad':
+ this._renderPadGrid();
+ break;
+ case 'live':
+ this._renderLiveArea();
+ break;
+ case 'keyboard':
+ this.keyboard?.enterTab();
+ break;
+ case 'arranger':
+ this._initArrangerTab();
+ break;
}
-
- _filterAndRenderLibrary() {
- this.libraryFeature?.filterAndRender();
+ }
+
+ // ── Global output selector (header) ────────────────────────
+ async _loadHeaderOutputDevices() {
+ return this.outputRouter?.loadDevices();
+ }
+ _refreshHeaderOutputUI() {
+ this.outputRouter?.refreshUI();
+ }
+ _toggleHeaderOutput() {
+ this.outputRouter?.toggleMode();
+ }
+ _setGlobalOutput(next) {
+ this.outputRouter?.setOutput(next);
+ }
+ _panicCurrentDevice(target) {
+ this.outputRouter?.panicTarget(target);
+ }
+ _getOutputTarget(fallbackSynth) {
+ return this.outputRouter?.getTarget(fallbackSynth) ?? fallbackSynth;
+ }
+
+ close() {
+ if (!this._arrDirty) {
+ super.close();
+ return;
}
-
-
- async _deleteLoopById(id) {
- try {
- await this.api.sendCommand('loop_delete', { loopId: id });
- this._fetchLoopDataCache.delete(id);
- // Remove from pad slots if assigned (cross-feature cleanup)
- this.padFeature?.cleanupSlotsForLoop(id);
- // Remove from live playing
- this._liveStop(id);
- await this._loadLibrary();
- if (this.currentArrangementId && this.blocks.some(b => b.loop_id === id)) {
- await this._loadArrangementById(this.currentArrangementId);
- }
- } catch (err) {
- LoopUtils.handleError(err, 'manager.deleteLoop', {
- toast: this.t('loopManager.errDeleteLoop')
- });
- }
+ // Confirmation accessible (AUDIT §A1).
+ const doSuperClose = () => super.close();
+ LoopUtils.confirm(this.t('loopManager.confirmDiscardChanges'), {
+ icon: '⚠️',
+ danger: true
+ }).then((ok) => {
+ if (ok) doSuperClose();
+ });
+ }
+
+ onClose() {
+ if (this._autoSaveTimer) {
+ clearTimeout(this._autoSaveTimer);
+ this._autoSaveTimer = null;
}
-
- _onLoopSaved(loopId) {
- this._fetchLoopDataCache.delete(loopId);
- this._loadLibrary();
+ this._closeBlockMenu();
+ this._stopAllPads();
+ this._liveStopAll();
+ this._stopArrangerPlay();
+ this._stopPlaybarRAF();
+ this._closePadPicker();
+ this.keyboard?.stopAllNotes();
+ if (this.keyboard?.mounted) this.keyboard.unmount();
+ // Coupe les timers/UI résiduels du Pad et de l'Arranger (AUDIT §L9, §L10).
+ if (typeof this._padClearLongPress === 'function') this._padClearLongPress();
+ this._hideDropPreview();
+ document.removeEventListener('mouseup', this._boundDocMouseUp);
+ document.removeEventListener('mousemove', this._boundDocMouseMove);
+ document.removeEventListener('keydown', this._boundKeyDown);
+ }
+
+ // =========================================================
+ // KEYBOARD SHORTCUTS
+ // =========================================================
+
+ // Delegates to events sub-feature (extracted per audit §1.3)
+ _onKeyDown(e) {
+ return this.events?._onKeyDown(e);
+ }
+ _switchTab(tab) {
+ return this.events?._switchTab(tab);
+ }
+ _attachEvents() {
+ return this.events?._attachEvents();
+ }
+ _onClick(e) {
+ return this.events?._onClick(e);
+ }
+ _onContextMenu(e) {
+ return this.events?._onContextMenu(e);
+ }
+ _openBlockMenu(x, y) {
+ return this.events?._openBlockMenu(x, y);
+ }
+ _closeBlockMenu() {
+ return this.events?._closeBlockMenu();
+ }
+ _onChange(e) {
+ return this.events?._onChange(e);
+ }
+ _onInput(e) {
+ return this.events?._onInput(e);
+ }
+
+ // =========================================================
+ // LIBRARY
+ // =========================================================
+
+ async _loadLibrary() {
+ return this.libraryFeature?.loadLibrary();
+ }
+
+ _filterAndRenderLibrary() {
+ this.libraryFeature?.filterAndRender();
+ }
+
+ async _deleteLoopById(id) {
+ try {
+ await this.api.sendCommand('loop_delete', { loopId: id });
+ this._fetchLoopDataCache.delete(id);
+ // Remove from pad slots if assigned (cross-feature cleanup)
+ this.padFeature?.cleanupSlotsForLoop(id);
+ // Remove from live playing
+ this._liveStop(id);
+ await this._loadLibrary();
+ if (this.currentArrangementId && this.blocks.some((b) => b.loop_id === id)) {
+ await this._loadArrangementById(this.currentArrangementId);
+ }
+ } catch (err) {
+ LoopUtils.handleError(err, 'manager.deleteLoop', {
+ toast: this.t('loopManager.errDeleteLoop')
+ });
}
-
- _gmProgramName(prog) { return this.view?.gmProgramName(prog) ?? `Program ${prog}`; }
- _instrIconHtml(prog, kind, extraClass) { return this.view?.instrIconHtml(prog, kind, extraClass) ?? ""; }
-
- // =========================================================
- // PAD TAB
- // =========================================================
-
- async _initPadSynth() { return this.padFeature?.initSynth(); }
- _renderPadGrid() { this.padFeature?.renderGrid(); }
- _syncPadControls() { this.padFeature?._syncControls(); }
- _setPadCols(v) { this.padFeature?.setCols(v); }
- _setPadRows(v) { this.padFeature?.setRows(v); }
- _adjustPadCols(d) { this.padFeature?.adjustCols(d); }
- _adjustPadRows(d) { this.padFeature?.adjustRows(d); }
- _setPadPlayMode(mode) { this.padFeature?.setPlayMode(mode); }
- _setPadQuantize(q) { this.padFeature?.setQuantize(q); }
- async _triggerPad(i, opts){ return this.padFeature?.trigger(i, opts); }
- _stopPad(i) { this.padFeature?.stop(i); }
- _stopAllPads() { this.padFeature?.stopAll(); }
- _assignPadSlot(i, loopId) { this.padFeature?.assignSlot(i, loopId); }
- _openPadPicker(i, anchor) { this.padFeature?.openPicker(i, anchor); }
- _closePadPicker() { this.padFeature?.closePicker(); }
- _persistPadLayout() { this.padFeature?._persist(); }
- _loadPadLayout() { this.padFeature?.load(); }
- async _clearAllPads() { return this.padFeature?.clearAll(); }
-
-
- // =========================================================
- // LIVE TAB
- // =========================================================
-
- async _initLiveSynth() { return this.liveFeature?.initSynth(); }
- _renderLiveArea() { this.liveFeature?.renderArea(); }
- async _liveTrigger(id) { return this.liveFeature?.trigger(id); }
- _liveStop(id) { this.liveFeature?.stop(id); }
- _liveStopAll() { this.liveFeature?.stopAll(); }
- // =========================================================
- // ARRANGER — delegated to LoopManagerArrangerFeature (audit §6.6)
- // State stays on the modal; the feature holds the methods only.
- // =========================================================
-
- async _initArrangerSynth() { return this.arrangerFeature?._initArrangerSynth(); }
- async _initArrangerTab() { return this.arrangerFeature?._initArrangerTab(); }
- _renderArrangerEmptyState() { this.arrangerFeature?._renderArrangerEmptyState(); }
- async _purgeEmptyArrangements() { return this.arrangerFeature?._purgeEmptyArrangements(); }
- async _loadArrangements() { return this.arrangerFeature?._loadArrangements(); }
- _renderArrList(arrs) { this.arrangerFeature?._renderArrList(arrs); }
- async _newArrangementConfirm() { return this.arrangerFeature?._newArrangementConfirm(); }
- async _requestLoadArrangement(id) { return this.arrangerFeature?._requestLoadArrangement(id); }
- async _newArrangement() { return this.arrangerFeature?._newArrangement(); }
- async _loadArrangementById(id) { return this.arrangerFeature?._loadArrangementById(id); }
- _snapshotArr() { return this.arrangerFeature?._snapshotArr(); }
- _resetArrHistory() { this.arrangerFeature?._resetArrHistory(); }
- _pushArrHistory() { this.arrangerFeature?._pushArrHistory(); }
- _arrUndo() { this.arrangerFeature?._arrUndo(); }
- _arrRedo() { this.arrangerFeature?._arrRedo(); }
- _restoreArrSnapshot(snap) { this.arrangerFeature?._restoreArrSnapshot(snap); }
- _markArrDirty(dirty) { this.arrangerFeature?._markArrDirty(dirty); }
- _refreshUndoButtons() { this.arrangerFeature?._refreshUndoButtons(); }
- _renderPalette() { this.arrangerFeature?._renderPalette(); }
- _renderTimeline() { this.arrangerFeature?._renderTimeline(); }
- _renderMinimap() { this.arrangerFeature?._renderMinimap(); }
- _renderRuler() { this.arrangerFeature?._renderRuler(); }
- _renderArrangerStartMarker() { this.arrangerFeature?._renderArrangerStartMarker(); }
- _renderTracks() { this.arrangerFeature?._renderTracks(); }
- _buildTrackEl(track) { return this.arrangerFeature?._buildTrackEl(track); }
- _isTrackAudible(id) { return this.arrangerFeature?._isTrackAudible(id); }
- _toggleTrackMute(id) { this.arrangerFeature?._toggleTrackMute(id); }
- _toggleTrackSolo(id) { this.arrangerFeature?._toggleTrackSolo(id); }
- _buildCells(trackId, BAR_W) { return this.arrangerFeature?._buildCells(trackId, BAR_W); }
- _toggleBlockSelection(id, add) { this.arrangerFeature?._toggleBlockSelection(id, add); }
- _clearBlockSelection() { this.arrangerFeature?._clearBlockSelection(); }
- _refreshBlockSelectionUI() { this.arrangerFeature?._refreshBlockSelectionUI(); }
- async _deleteSelectedBlocks() { return this.arrangerFeature?._deleteSelectedBlocks(); }
- _copySelectedBlocks() { this.arrangerFeature?._copySelectedBlocks(); }
- async _pasteBlocks(trk, bar) { return this.arrangerFeature?._pasteBlocks(trk, bar); }
- async _duplicateSelectedBlocks(){ return this.arrangerFeature?._duplicateSelectedBlocks(); }
- _nextFreeBar(trackId) { return this.arrangerFeature?._nextFreeBar(trackId); }
- _barWidth() { return this.arrangerFeature?._barWidth() ?? 0; }
- _arrZoom(factor) { this.arrangerFeature?._arrZoom(factor); }
- _arrZoomReset() { this.arrangerFeature?._arrZoomReset(); }
- _arrZoomV(factor) { this.arrangerFeature?._arrZoomV(factor); }
- _trackHeight() { return this.arrangerFeature?._trackHeight() ?? 0; }
- _toggleLoopPlayback() { this.arrangerFeature?._toggleLoopPlayback(); }
- _toggleCountIn() { this.arrangerFeature?._toggleCountIn(); }
- _barFromX(offsetX, barW) { return this.arrangerFeature?._barFromX(offsetX, barW); }
- _showDropPreview(cells, b, w) { this.arrangerFeature?._showDropPreview(cells, b, w); }
- _hideDropPreview() { this.arrangerFeature?._hideDropPreview(); }
- async _addTrack() { return this.arrangerFeature?._addTrack(); }
- async _deleteTrack(id) { return this.arrangerFeature?._deleteTrack(id); }
- async _addBlock(t, l, b, lb) { return this.arrangerFeature?._addBlock(t, l, b, lb); }
- async _moveBlock(id, t, b) { return this.arrangerFeature?._moveBlock(id, t, b); }
- async _changeReps(id, d) { return this.arrangerFeature?._changeReps(id, d); }
- async _deleteBlock(id) { return this.arrangerFeature?._deleteBlock(id); }
- async _saveArrangement(opts) { return this.arrangerFeature?._saveArrangement(opts); }
- async _deleteArrangement(id) { return this.arrangerFeature?._deleteArrangement(id); }
- async _duplicateArrangement(id) { return this.arrangerFeature?._duplicateArrangement(id); }
- _adjustArrTempo(d) { this.arrangerFeature?._adjustArrTempo(d); }
- _adjustArrBars(d) { this.arrangerFeature?._adjustArrBars(d); }
- async _playArrangement(bar) { return this.arrangerFeature?._playArrangement(bar); }
- _scheduleCountIn(target, sec) { this.arrangerFeature?._scheduleCountIn(target, sec); }
- _stopArrangerPlay() { this.arrangerFeature?._stopArrangerPlay(); }
- _startPlaybarRAF() { this.arrangerFeature?._startPlaybarRAF(); }
- _stopPlaybarRAF() { this.arrangerFeature?._stopPlaybarRAF(); }
- _renderArrangerPlayhead(s) { this.arrangerFeature?._renderArrangerPlayhead(s); }
- _scheduleAutoSave(delay) { this.arrangerFeature?._scheduleAutoSave(delay); }
- _onDocMouseMove(e) { this.arrangerFeature?._onDocMouseMove(e); }
- async _onDocMouseUp() { return this.arrangerFeature?._onDocMouseUp(); }
-
-
-
- _renderPlaybar() { return this.view?._renderPlaybar(); }
-
-
- // =========================================================
- // SHARED — FETCH LOOP DATA
- // =========================================================
-
- async _fetchLoopData(loopId) {
- if (this._fetchLoopDataCache.has(loopId)) return this._fetchLoopDataCache.get(loopId);
- try {
- const r = await this.api.sendCommand('loop_get', { loopId });
- this._fetchLoopDataCache.set(loopId, r.loop);
- return r.loop;
- } catch (err) {
- LoopUtils.handleError(err, 'loop.fetch');
- return null;
- }
+ }
+
+ _onLoopSaved(loopId) {
+ this._fetchLoopDataCache.delete(loopId);
+ this._loadLibrary();
+ }
+
+ _gmProgramName(prog) {
+ return this.view?.gmProgramName(prog) ?? `Program ${prog}`;
+ }
+ _instrIconHtml(prog, kind, extraClass) {
+ return this.view?.instrIconHtml(prog, kind, extraClass) ?? '';
+ }
+
+ // =========================================================
+ // PAD TAB
+ // =========================================================
+
+ async _initPadSynth() {
+ return this.padFeature?.initSynth();
+ }
+ _renderPadGrid() {
+ this.padFeature?.renderGrid();
+ }
+ _syncPadControls() {
+ this.padFeature?._syncControls();
+ }
+ _setPadCols(v) {
+ this.padFeature?.setCols(v);
+ }
+ _setPadRows(v) {
+ this.padFeature?.setRows(v);
+ }
+ _adjustPadCols(d) {
+ this.padFeature?.adjustCols(d);
+ }
+ _adjustPadRows(d) {
+ this.padFeature?.adjustRows(d);
+ }
+ _setPadPlayMode(mode) {
+ this.padFeature?.setPlayMode(mode);
+ }
+ _setPadQuantize(q) {
+ this.padFeature?.setQuantize(q);
+ }
+ async _triggerPad(i, opts) {
+ return this.padFeature?.trigger(i, opts);
+ }
+ _stopPad(i) {
+ this.padFeature?.stop(i);
+ }
+ _stopAllPads() {
+ this.padFeature?.stopAll();
+ }
+ _assignPadSlot(i, loopId) {
+ this.padFeature?.assignSlot(i, loopId);
+ }
+ _openPadPicker(i, anchor) {
+ this.padFeature?.openPicker(i, anchor);
+ }
+ _closePadPicker() {
+ this.padFeature?.closePicker();
+ }
+ _persistPadLayout() {
+ this.padFeature?._persist();
+ }
+ _loadPadLayout() {
+ this.padFeature?.load();
+ }
+ async _clearAllPads() {
+ return this.padFeature?.clearAll();
+ }
+
+ // =========================================================
+ // LIVE TAB
+ // =========================================================
+
+ async _initLiveSynth() {
+ return this.liveFeature?.initSynth();
+ }
+ _renderLiveArea() {
+ this.liveFeature?.renderArea();
+ }
+ async _liveTrigger(id) {
+ return this.liveFeature?.trigger(id);
+ }
+ _liveStop(id) {
+ this.liveFeature?.stop(id);
+ }
+ _liveStopAll() {
+ this.liveFeature?.stopAll();
+ }
+ // =========================================================
+ // ARRANGER — delegated to LoopManagerArrangerFeature (audit §6.6)
+ // State stays on the modal; the feature holds the methods only.
+ // =========================================================
+
+ async _initArrangerSynth() {
+ return this.arrangerFeature?._initArrangerSynth();
+ }
+ async _initArrangerTab() {
+ return this.arrangerFeature?._initArrangerTab();
+ }
+ _renderArrangerEmptyState() {
+ this.arrangerFeature?._renderArrangerEmptyState();
+ }
+ async _purgeEmptyArrangements() {
+ return this.arrangerFeature?._purgeEmptyArrangements();
+ }
+ async _loadArrangements() {
+ return this.arrangerFeature?._loadArrangements();
+ }
+ _renderArrList(arrs) {
+ this.arrangerFeature?._renderArrList(arrs);
+ }
+ async _newArrangementConfirm() {
+ return this.arrangerFeature?._newArrangementConfirm();
+ }
+ async _requestLoadArrangement(id) {
+ return this.arrangerFeature?._requestLoadArrangement(id);
+ }
+ async _newArrangement() {
+ return this.arrangerFeature?._newArrangement();
+ }
+ async _loadArrangementById(id) {
+ return this.arrangerFeature?._loadArrangementById(id);
+ }
+ _snapshotArr() {
+ return this.arrangerFeature?._snapshotArr();
+ }
+ _resetArrHistory() {
+ this.arrangerFeature?._resetArrHistory();
+ }
+ _pushArrHistory() {
+ this.arrangerFeature?._pushArrHistory();
+ }
+ _arrUndo() {
+ this.arrangerFeature?._arrUndo();
+ }
+ _arrRedo() {
+ this.arrangerFeature?._arrRedo();
+ }
+ _restoreArrSnapshot(snap) {
+ this.arrangerFeature?._restoreArrSnapshot(snap);
+ }
+ _markArrDirty(dirty) {
+ this.arrangerFeature?._markArrDirty(dirty);
+ }
+ _refreshUndoButtons() {
+ this.arrangerFeature?._refreshUndoButtons();
+ }
+ _renderPalette() {
+ this.arrangerFeature?._renderPalette();
+ }
+ _renderTimeline() {
+ this.arrangerFeature?._renderTimeline();
+ }
+ _renderMinimap() {
+ this.arrangerFeature?._renderMinimap();
+ }
+ _renderRuler() {
+ this.arrangerFeature?._renderRuler();
+ }
+ _renderArrangerStartMarker() {
+ this.arrangerFeature?._renderArrangerStartMarker();
+ }
+ _renderTracks() {
+ this.arrangerFeature?._renderTracks();
+ }
+ _buildTrackEl(track) {
+ return this.arrangerFeature?._buildTrackEl(track);
+ }
+ _isTrackAudible(id) {
+ return this.arrangerFeature?._isTrackAudible(id);
+ }
+ _toggleTrackMute(id) {
+ this.arrangerFeature?._toggleTrackMute(id);
+ }
+ _toggleTrackSolo(id) {
+ this.arrangerFeature?._toggleTrackSolo(id);
+ }
+ _buildCells(trackId, BAR_W) {
+ return this.arrangerFeature?._buildCells(trackId, BAR_W);
+ }
+ _toggleBlockSelection(id, add) {
+ this.arrangerFeature?._toggleBlockSelection(id, add);
+ }
+ _clearBlockSelection() {
+ this.arrangerFeature?._clearBlockSelection();
+ }
+ _refreshBlockSelectionUI() {
+ this.arrangerFeature?._refreshBlockSelectionUI();
+ }
+ async _deleteSelectedBlocks() {
+ return this.arrangerFeature?._deleteSelectedBlocks();
+ }
+ _copySelectedBlocks() {
+ this.arrangerFeature?._copySelectedBlocks();
+ }
+ async _pasteBlocks(trk, bar) {
+ return this.arrangerFeature?._pasteBlocks(trk, bar);
+ }
+ async _duplicateSelectedBlocks() {
+ return this.arrangerFeature?._duplicateSelectedBlocks();
+ }
+ _nextFreeBar(trackId) {
+ return this.arrangerFeature?._nextFreeBar(trackId);
+ }
+ _barWidth() {
+ return this.arrangerFeature?._barWidth() ?? 0;
+ }
+ _arrZoom(factor) {
+ this.arrangerFeature?._arrZoom(factor);
+ }
+ _arrZoomReset() {
+ this.arrangerFeature?._arrZoomReset();
+ }
+ _arrZoomV(factor) {
+ this.arrangerFeature?._arrZoomV(factor);
+ }
+ _trackHeight() {
+ return this.arrangerFeature?._trackHeight() ?? 0;
+ }
+ _toggleLoopPlayback() {
+ this.arrangerFeature?._toggleLoopPlayback();
+ }
+ _toggleCountIn() {
+ this.arrangerFeature?._toggleCountIn();
+ }
+ _barFromX(offsetX, barW) {
+ return this.arrangerFeature?._barFromX(offsetX, barW);
+ }
+ _showDropPreview(cells, b, w) {
+ this.arrangerFeature?._showDropPreview(cells, b, w);
+ }
+ _hideDropPreview() {
+ this.arrangerFeature?._hideDropPreview();
+ }
+ async _addTrack() {
+ return this.arrangerFeature?._addTrack();
+ }
+ async _deleteTrack(id) {
+ return this.arrangerFeature?._deleteTrack(id);
+ }
+ async _addBlock(t, l, b, lb) {
+ return this.arrangerFeature?._addBlock(t, l, b, lb);
+ }
+ async _moveBlock(id, t, b) {
+ return this.arrangerFeature?._moveBlock(id, t, b);
+ }
+ async _changeReps(id, d) {
+ return this.arrangerFeature?._changeReps(id, d);
+ }
+ async _deleteBlock(id) {
+ return this.arrangerFeature?._deleteBlock(id);
+ }
+ async _saveArrangement(opts) {
+ return this.arrangerFeature?._saveArrangement(opts);
+ }
+ async _deleteArrangement(id) {
+ return this.arrangerFeature?._deleteArrangement(id);
+ }
+ async _duplicateArrangement(id) {
+ return this.arrangerFeature?._duplicateArrangement(id);
+ }
+ _adjustArrTempo(d) {
+ this.arrangerFeature?._adjustArrTempo(d);
+ }
+ _adjustArrBars(d) {
+ this.arrangerFeature?._adjustArrBars(d);
+ }
+ async _playArrangement(bar) {
+ return this.arrangerFeature?._playArrangement(bar);
+ }
+ _scheduleCountIn(target, sec) {
+ this.arrangerFeature?._scheduleCountIn(target, sec);
+ }
+ _stopArrangerPlay() {
+ this.arrangerFeature?._stopArrangerPlay();
+ }
+ _startPlaybarRAF() {
+ this.arrangerFeature?._startPlaybarRAF();
+ }
+ _stopPlaybarRAF() {
+ this.arrangerFeature?._stopPlaybarRAF();
+ }
+ _renderArrangerPlayhead(s) {
+ this.arrangerFeature?._renderArrangerPlayhead(s);
+ }
+ _scheduleAutoSave(delay) {
+ this.arrangerFeature?._scheduleAutoSave(delay);
+ }
+ _onDocMouseMove(e) {
+ this.arrangerFeature?._onDocMouseMove(e);
+ }
+ async _onDocMouseUp() {
+ return this.arrangerFeature?._onDocMouseUp();
+ }
+
+ _renderPlaybar() {
+ return this.view?._renderPlaybar();
+ }
+
+ // =========================================================
+ // SHARED — FETCH LOOP DATA
+ // =========================================================
+
+ async _fetchLoopData(loopId) {
+ if (this._fetchLoopDataCache.has(loopId)) return this._fetchLoopDataCache.get(loopId);
+ try {
+ const r = await this.api.sendCommand('loop_get', { loopId });
+ this._fetchLoopDataCache.set(loopId, r.loop);
+ return r.loop;
+ } catch (err) {
+ LoopUtils.handleError(err, 'loop.fetch');
+ return null;
}
-
+ }
}
// Expose both names for backward compatibility
if (typeof window !== 'undefined') {
- window.LoopManagerModal = LoopManagerModal;
- window.LoopCreatorModal = LoopManagerModal;
+ window.LoopManagerModal = LoopManagerModal;
+ window.LoopCreatorModal = LoopManagerModal;
}
diff --git a/public/js/features/LoopCreatorModalEvents.js b/public/js/features/LoopCreatorModalEvents.js
index 7666bbf96..e862e47e3 100644
--- a/public/js/features/LoopCreatorModalEvents.js
+++ b/public/js/features/LoopCreatorModalEvents.js
@@ -20,69 +20,117 @@
// ============================================================================
(function () {
- 'use strict';
+ 'use strict';
- class LoopCreatorModalEvents {
- /** @param {LoopCreatorModal} parent */
- constructor(parent) {
- this.parent = parent;
- }
+ class LoopCreatorModalEvents {
+ /** @param {LoopCreatorModal} parent */
+ constructor(parent) {
+ this.parent = parent;
+ }
_onKeyDown(e) {
- const t = e.target;
- const tag = (t?.tagName || '').toLowerCase();
+ const t = e.target;
+ const tag = (t?.tagName || '').toLowerCase();
- // Navigation clavier dans la tablist (APG tabs pattern, AUDIT §A2).
- // Capture en premier — pas conditionné par l'input/textarea check
- // car le focus est sur un bouton tab.
- if (t?.classList?.contains('lc-tab') && t.getAttribute('role') === 'tab') {
- const tabs = ['library', 'pad', 'live', 'keyboard', 'arranger'];
- const i = tabs.indexOf(t.dataset.tab);
- if (i >= 0) {
- let next = -1;
- if (e.key === 'ArrowRight') next = (i + 1) % tabs.length;
- else if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length;
- else if (e.key === 'Home') next = 0;
- else if (e.key === 'End') next = tabs.length - 1;
- if (next >= 0) {
- e.preventDefault();
- this._switchTab(tabs[next]);
- this.parent.$(`#lc-tab-${tabs[next]}`)?.focus();
- return;
- }
- }
+ // Navigation clavier dans la tablist (APG tabs pattern, AUDIT §A2).
+ // Capture en premier — pas conditionné par l'input/textarea check
+ // car le focus est sur un bouton tab.
+ if (t?.classList?.contains('lc-tab') && t.getAttribute('role') === 'tab') {
+ const tabs = ['library', 'pad', 'live', 'keyboard', 'arranger'];
+ const i = tabs.indexOf(t.dataset.tab);
+ if (i >= 0) {
+ let next = -1;
+ if (e.key === 'ArrowRight') next = (i + 1) % tabs.length;
+ else if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length;
+ else if (e.key === 'Home') next = 0;
+ else if (e.key === 'End') next = tabs.length - 1;
+ if (next >= 0) {
+ e.preventDefault();
+ this._switchTab(tabs[next]);
+ this.parent.$(`#lc-tab-${tabs[next]}`)?.focus();
+ return;
+ }
}
+ }
- if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return;
- if (this.parent._loopEditor?.isOpen) return; // editor handles its own shortcuts
+ if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return;
+ if (this.parent._loopEditor?.isOpen) return; // editor handles its own shortcuts
- const mod = e.ctrlKey || e.metaKey;
+ const mod = e.ctrlKey || e.metaKey;
- if (this.parent.activeTab === 'arranger') {
- if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); this.parent._arrUndo(); return; }
- if (mod && e.key.toLowerCase() === 'z' && e.shiftKey) { e.preventDefault(); this.parent._arrRedo(); return; }
- if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); this.parent._arrRedo(); return; }
- if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); this.parent._saveArrangement(); return; }
- if (e.key === ' ') { e.preventDefault(); this.parent.isArrangerPlaying ? this.parent._stopArrangerPlay() : this.parent._playArrangement(this.parent._arrangerStartBar); return; }
- if (e.key === 'Escape') { this.parent._stopArrangerPlay(); this.parent._clearBlockSelection(); return; }
- if ((e.key === 'Delete' || e.key === 'Backspace') && this.parent._selectedBlocks.size) {
- e.preventDefault(); this.parent._deleteSelectedBlocks(); return;
- }
- if (mod && e.key.toLowerCase() === 'a') {
- e.preventDefault();
- this.parent._selectedBlocks = new Set(this.parent.blocks.map(b => b.id));
- this.parent._refreshBlockSelectionUI();
- return;
- }
- if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); this.parent._copySelectedBlocks(); return; }
- if (mod && e.key.toLowerCase() === 'x') { e.preventDefault(); this.parent._copySelectedBlocks(); this.parent._deleteSelectedBlocks(); return; }
- if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); this.parent._pasteBlocks(); return; }
- if (mod && e.key.toLowerCase() === 'd') { e.preventDefault(); this.parent._duplicateSelectedBlocks(); return; }
+ if (this.parent.activeTab === 'arranger') {
+ if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
+ e.preventDefault();
+ this.parent._arrUndo();
+ return;
+ }
+ if (mod && e.key.toLowerCase() === 'z' && e.shiftKey) {
+ e.preventDefault();
+ this.parent._arrRedo();
+ return;
+ }
+ if (mod && e.key.toLowerCase() === 'y') {
+ e.preventDefault();
+ this.parent._arrRedo();
+ return;
+ }
+ if (mod && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ this.parent._saveArrangement();
+ return;
+ }
+ if (e.key === ' ') {
+ e.preventDefault();
+ this.parent.isArrangerPlaying
+ ? this.parent._stopArrangerPlay()
+ : this.parent._playArrangement(this.parent._arrangerStartBar);
+ return;
}
+ if (e.key === 'Escape') {
+ this.parent._stopArrangerPlay();
+ this.parent._clearBlockSelection();
+ return;
+ }
+ if ((e.key === 'Delete' || e.key === 'Backspace') && this.parent._selectedBlocks.size) {
+ e.preventDefault();
+ this.parent._deleteSelectedBlocks();
+ return;
+ }
+ if (mod && e.key.toLowerCase() === 'a') {
+ e.preventDefault();
+ this.parent._selectedBlocks = new Set(this.parent.blocks.map((b) => b.id));
+ this.parent._refreshBlockSelectionUI();
+ return;
+ }
+ if (mod && e.key.toLowerCase() === 'c') {
+ e.preventDefault();
+ this.parent._copySelectedBlocks();
+ return;
+ }
+ if (mod && e.key.toLowerCase() === 'x') {
+ e.preventDefault();
+ this.parent._copySelectedBlocks();
+ this.parent._deleteSelectedBlocks();
+ return;
+ }
+ if (mod && e.key.toLowerCase() === 'v') {
+ e.preventDefault();
+ this.parent._pasteBlocks();
+ return;
+ }
+ if (mod && e.key.toLowerCase() === 'd') {
+ e.preventDefault();
+ this.parent._duplicateSelectedBlocks();
+ return;
+ }
+ }
- if (this.parent.activeTab === 'live') {
- if (e.key === 'Escape') { this.parent._liveStopAll(); return; }
+ if (this.parent.activeTab === 'live') {
+ if (e.key === 'Escape') {
+ this.parent._liveStopAll();
+ return;
}
+ }
}
// =========================================================
@@ -90,33 +138,37 @@
// =========================================================
_switchTab(tab) {
- // Close the pad assignment picker if leaving the Pad tab
- if (this.parent.activeTab === 'pad' && tab !== 'pad' && this.parent._padPickerIndex !== null) {
- this.parent._closePadPicker();
- }
- this.parent.activeTab = tab;
- this.parent.$$('.lc-tab').forEach(btn => {
- const active = btn.dataset.tab === tab;
- btn.classList.toggle('lc-tab--active', active);
- btn.setAttribute('aria-selected', active ? 'true' : 'false');
- // APG : tabindex roving — un seul tab focusable à la fois.
- btn.setAttribute('tabindex', active ? '0' : '-1');
- });
- this.parent.$$('.lc-pane').forEach(pane => {
- pane.classList.toggle('lc-pane--hidden', !pane.id.endsWith(tab));
- });
- const saveBtn = this.parent.$('#lc-header-save');
- if (saveBtn) saveBtn.style.display = tab === 'arranger' ? '' : 'none';
+ // Close the pad assignment picker if leaving the Pad tab
+ if (
+ this.parent.activeTab === 'pad' &&
+ tab !== 'pad' &&
+ this.parent._padPickerIndex !== null
+ ) {
+ this.parent._closePadPicker();
+ }
+ this.parent.activeTab = tab;
+ this.parent.$$('.lc-tab').forEach((btn) => {
+ const active = btn.dataset.tab === tab;
+ btn.classList.toggle('lc-tab--active', active);
+ btn.setAttribute('aria-selected', active ? 'true' : 'false');
+ // APG : tabindex roving — un seul tab focusable à la fois.
+ btn.setAttribute('tabindex', active ? '0' : '-1');
+ });
+ this.parent.$$('.lc-pane').forEach((pane) => {
+ pane.classList.toggle('lc-pane--hidden', !pane.id.endsWith(tab));
+ });
+ const saveBtn = this.parent.$('#lc-header-save');
+ if (saveBtn) saveBtn.style.display = tab === 'arranger' ? '' : 'none';
- // The embedded keyboard panel can only live in one host at a time, so
- // unmount it whenever the user leaves the Keyboard tab.
- if (tab !== 'keyboard' && this.parent.keyboard?.mounted) this.parent.keyboard.unmount();
+ // The embedded keyboard panel can only live in one host at a time, so
+ // unmount it whenever the user leaves the Keyboard tab.
+ if (tab !== 'keyboard' && this.parent.keyboard?.mounted) this.parent.keyboard.unmount();
- if (tab === 'library') this.parent._filterAndRenderLibrary();
- if (tab === 'pad') this.parent._renderPadGrid();
- if (tab === 'live') this.parent._renderLiveArea();
- if (tab === 'keyboard') this.parent.keyboard?.enterTab();
- if (tab === 'arranger') this.parent._initArrangerTab();
+ if (tab === 'library') this.parent._filterAndRenderLibrary();
+ if (tab === 'pad') this.parent._renderPadGrid();
+ if (tab === 'live') this.parent._renderLiveArea();
+ if (tab === 'keyboard') this.parent.keyboard?.enterTab();
+ if (tab === 'arranger') this.parent._initArrangerTab();
}
// =========================================================
@@ -124,197 +176,287 @@
// =========================================================
_attachEvents() {
- this.parent.dialog.addEventListener('click', (e) => this._onClick(e));
- this.parent.dialog.addEventListener('change', (e) => this._onChange(e));
- this.parent.dialog.addEventListener('input', (e) => this._onInput(e));
- this.parent.dialog.addEventListener('contextmenu', (e) => this._onContextMenu(e));
+ this.parent.dialog.addEventListener('click', (e) => this._onClick(e));
+ this.parent.dialog.addEventListener('change', (e) => this._onChange(e));
+ this.parent.dialog.addEventListener('input', (e) => this._onInput(e));
+ this.parent.dialog.addEventListener('contextmenu', (e) => this._onContextMenu(e));
}
_onClick(e) {
- // Close pad picker on outside click
- if (this.parent._padPickerIndex !== null && !e.target.closest('#lm-pad-picker') && !e.target.closest('.lm-pad-cell')) {
- this.parent._closePadPicker();
- }
+ // Close pad picker on outside click
+ if (
+ this.parent._padPickerIndex !== null &&
+ !e.target.closest('#lm-pad-picker') &&
+ !e.target.closest('.lm-pad-cell')
+ ) {
+ this.parent._closePadPicker();
+ }
- const tabBtn = e.target.closest('.lc-tab[data-tab]');
- if (tabBtn) { this._switchTab(tabBtn.dataset.tab); return; }
+ const tabBtn = e.target.closest('.lc-tab[data-tab]');
+ if (tabBtn) {
+ this._switchTab(tabBtn.dataset.tab);
+ return;
+ }
- const btn = e.target.closest('[data-action]');
- if (!btn) return;
- const a = btn.dataset.action;
- switch (a) {
- // Global stop (header button)
- case 'stop-all-playback': this.parent._stopAllPads(); this.parent._liveStopAll(); this.parent._stopArrangerPlay(); this.parent.keyboard?.stopAllNotes(); break;
- case 'toggle-output': this.parent._toggleHeaderOutput(); break;
- // Library
- case 'new-loop': this.parent._loopEditor.open(); break;
- // Pad
- case 'pad-clear-all':this.parent._clearAllPads(); break;
- case 'pad-cols-dec': this.parent._adjustPadCols(-1); break;
- case 'pad-cols-inc': this.parent._adjustPadCols(+1); break;
- case 'pad-rows-dec': this.parent._adjustPadRows(-1); break;
- case 'pad-rows-inc': this.parent._adjustPadRows(+1); break;
- case 'pad-set-mode': this.parent._setPadPlayMode(btn.dataset.mode); break;
- case 'pad-set-quantize': this.parent._setPadQuantize(btn.dataset.quantize); break;
- // Live
- case 'live-stop-all': this.parent._liveStopAll(); break;
- case 'live-trigger': {
- const loopId = parseInt(btn.dataset.loopId);
- if (!isNaN(loopId)) this.parent._liveTrigger(loopId);
- break;
- }
- // Arranger
- case 'arr-tempo-dec': this.parent._adjustArrTempo(-1); break;
- case 'arr-tempo-inc': this.parent._adjustArrTempo(+1); break;
- case 'arr-bars-dec': this.parent._adjustArrBars(-4); break;
- case 'arr-bars-inc': this.parent._adjustArrBars(+4); break;
- case 'arr-add-track': this.parent._addTrack(); break;
- case 'arr-play': this.parent._playArrangement(this.parent._arrangerStartBar); break;
- case 'arr-stop': this.parent._stopArrangerPlay(); break;
- case 'arr-new': this.parent._newArrangementConfirm(); break;
- case 'arr-zoom-in': this.parent._arrZoom(1.5); break;
- case 'arr-zoom-out': this.parent._arrZoom(1 / 1.5); break;
- case 'arr-zoom-reset': this.parent._arrZoomReset(); break;
- case 'arr-zoomv-in': this.parent._arrZoomV(1.3); break;
- case 'arr-zoomv-out': this.parent._arrZoomV(1 / 1.3); break;
- case 'arr-toggle-loop': this.parent._toggleLoopPlayback(); break;
- case 'arr-toggle-countin': this.parent._toggleCountIn(); break;
- case 'arr-undo': this.parent._arrUndo(); break;
- case 'arr-redo': this.parent._arrRedo(); break;
- case 'save-arrangement': this.parent._saveArrangement(); break;
- case 'close': this.parent.close(); break;
+ const btn = e.target.closest('[data-action]');
+ if (!btn) return;
+ const a = btn.dataset.action;
+ switch (a) {
+ // Global stop (header button)
+ case 'stop-all-playback':
+ this.parent._stopAllPads();
+ this.parent._liveStopAll();
+ this.parent._stopArrangerPlay();
+ this.parent.keyboard?.stopAllNotes();
+ break;
+ case 'toggle-output':
+ this.parent._toggleHeaderOutput();
+ break;
+ // Library
+ case 'new-loop':
+ this.parent._loopEditor.open();
+ break;
+ // Pad
+ case 'pad-clear-all':
+ this.parent._clearAllPads();
+ break;
+ case 'pad-cols-dec':
+ this.parent._adjustPadCols(-1);
+ break;
+ case 'pad-cols-inc':
+ this.parent._adjustPadCols(+1);
+ break;
+ case 'pad-rows-dec':
+ this.parent._adjustPadRows(-1);
+ break;
+ case 'pad-rows-inc':
+ this.parent._adjustPadRows(+1);
+ break;
+ case 'pad-set-mode':
+ this.parent._setPadPlayMode(btn.dataset.mode);
+ break;
+ case 'pad-set-quantize':
+ this.parent._setPadQuantize(btn.dataset.quantize);
+ break;
+ // Live
+ case 'live-stop-all':
+ this.parent._liveStopAll();
+ break;
+ case 'live-trigger': {
+ const loopId = parseInt(btn.dataset.loopId);
+ if (!isNaN(loopId)) this.parent._liveTrigger(loopId);
+ break;
}
+ // Arranger
+ case 'arr-tempo-dec':
+ this.parent._adjustArrTempo(-1);
+ break;
+ case 'arr-tempo-inc':
+ this.parent._adjustArrTempo(+1);
+ break;
+ case 'arr-bars-dec':
+ this.parent._adjustArrBars(-4);
+ break;
+ case 'arr-bars-inc':
+ this.parent._adjustArrBars(+4);
+ break;
+ case 'arr-add-track':
+ this.parent._addTrack();
+ break;
+ case 'arr-play':
+ this.parent._playArrangement(this.parent._arrangerStartBar);
+ break;
+ case 'arr-stop':
+ this.parent._stopArrangerPlay();
+ break;
+ case 'arr-new':
+ this.parent._newArrangementConfirm();
+ break;
+ case 'arr-zoom-in':
+ this.parent._arrZoom(1.5);
+ break;
+ case 'arr-zoom-out':
+ this.parent._arrZoom(1 / 1.5);
+ break;
+ case 'arr-zoom-reset':
+ this.parent._arrZoomReset();
+ break;
+ case 'arr-zoomv-in':
+ this.parent._arrZoomV(1.3);
+ break;
+ case 'arr-zoomv-out':
+ this.parent._arrZoomV(1 / 1.3);
+ break;
+ case 'arr-toggle-loop':
+ this.parent._toggleLoopPlayback();
+ break;
+ case 'arr-toggle-countin':
+ this.parent._toggleCountIn();
+ break;
+ case 'arr-undo':
+ this.parent._arrUndo();
+ break;
+ case 'arr-redo':
+ this.parent._arrRedo();
+ break;
+ case 'save-arrangement':
+ this.parent._saveArrangement();
+ break;
+ case 'close':
+ this.parent.close();
+ break;
+ }
}
_onContextMenu(e) {
- const cell = e.target.closest('.lm-pad-cell[data-pad-index]');
- if (cell) {
- e.preventDefault();
- this.parent._openPadPicker(parseInt(cell.dataset.padIndex), cell);
- return;
- }
- const blockEl = e.target.closest('.la-block[data-block-id]');
- if (blockEl) {
- e.preventDefault();
- const bid = parseInt(blockEl.dataset.blockId);
- if (!this.parent._selectedBlocks.has(bid)) {
- this.parent._selectedBlocks.clear();
- this.parent._selectedBlocks.add(bid);
- this.parent._refreshBlockSelectionUI();
- }
- this._openBlockMenu(e.clientX, e.clientY);
+ const cell = e.target.closest('.lm-pad-cell[data-pad-index]');
+ if (cell) {
+ e.preventDefault();
+ this.parent._openPadPicker(parseInt(cell.dataset.padIndex), cell);
+ return;
+ }
+ const blockEl = e.target.closest('.la-block[data-block-id]');
+ if (blockEl) {
+ e.preventDefault();
+ const bid = parseInt(blockEl.dataset.blockId);
+ if (!this.parent._selectedBlocks.has(bid)) {
+ this.parent._selectedBlocks.clear();
+ this.parent._selectedBlocks.add(bid);
+ this.parent._refreshBlockSelectionUI();
}
+ this._openBlockMenu(e.clientX, e.clientY);
+ }
}
_openBlockMenu(x, y) {
- this._closeBlockMenu();
- const menu = document.createElement('div');
- menu.className = 'la-block-menu';
- menu.style.left = x + 'px';
- menu.style.top = y + 'px';
- const items = [
- { action: 'duplicate', label: this.parent.t('loopManager.blockMenuDuplicate'), icon: '⎘' },
- { action: 'copy', label: this.parent.t('loopManager.blockMenuCopy'), icon: '⧉' },
- { action: 'reps-inc', label: this.parent.t('loopManager.blockMenuRepsInc'), icon: '+' },
- { action: 'reps-dec', label: this.parent.t('loopManager.blockMenuRepsDec'), icon: '−' },
- { action: 'delete', label: this.parent.t('loopManager.blockMenuDelete'), icon: '🗑', danger: true }
- ];
- menu.innerHTML = items.map(it =>
+ this._closeBlockMenu();
+ const menu = document.createElement('div');
+ menu.className = 'la-block-menu';
+ menu.style.left = x + 'px';
+ menu.style.top = y + 'px';
+ const items = [
+ { action: 'duplicate', label: this.parent.t('loopManager.blockMenuDuplicate'), icon: '⎘' },
+ { action: 'copy', label: this.parent.t('loopManager.blockMenuCopy'), icon: '⧉' },
+ { action: 'reps-inc', label: this.parent.t('loopManager.blockMenuRepsInc'), icon: '+' },
+ { action: 'reps-dec', label: this.parent.t('loopManager.blockMenuRepsDec'), icon: '−' },
+ {
+ action: 'delete',
+ label: this.parent.t('loopManager.blockMenuDelete'),
+ icon: '🗑',
+ danger: true
+ }
+ ];
+ menu.innerHTML = items
+ .map(
+ (it) =>
``
- ).join('');
- document.body.appendChild(menu);
- // Clamp position to viewport
- const rect = menu.getBoundingClientRect();
- if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
- if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
+ )
+ .join('');
+ document.body.appendChild(menu);
+ // Clamp position to viewport
+ const rect = menu.getBoundingClientRect();
+ if (rect.right > window.innerWidth)
+ menu.style.left = window.innerWidth - rect.width - 4 + 'px';
+ if (rect.bottom > window.innerHeight)
+ menu.style.top = window.innerHeight - rect.height - 4 + 'px';
- menu.addEventListener('click', (e) => {
- const btn = e.target.closest('[data-menu-action]');
- if (!btn) return;
- this._closeBlockMenu();
- const a = btn.dataset.menuAction;
- const ids = [...this.parent._selectedBlocks];
- switch (a) {
- case 'duplicate': this.parent._duplicateSelectedBlocks(); break;
- case 'copy': this.parent._copySelectedBlocks(); break;
- case 'reps-inc': ids.forEach(id => this.parent._changeReps(id, +1)); break;
- case 'reps-dec': ids.forEach(id => this.parent._changeReps(id, -1)); break;
- case 'delete': this.parent._deleteSelectedBlocks(); break;
- }
- });
- // Dismiss on outside click / escape
- this.parent._blockMenuEl = menu;
- this.parent._blockMenuDismiss = (ev) => {
- if (ev.type === 'keydown' && ev.key !== 'Escape') return;
- if (ev.type === 'mousedown' && menu.contains(ev.target)) return;
- this._closeBlockMenu();
- };
- setTimeout(() => {
- document.addEventListener('mousedown', this.parent._blockMenuDismiss);
- document.addEventListener('keydown', this.parent._blockMenuDismiss);
- }, 0);
+ menu.addEventListener('click', (e) => {
+ const btn = e.target.closest('[data-menu-action]');
+ if (!btn) return;
+ this._closeBlockMenu();
+ const a = btn.dataset.menuAction;
+ const ids = [...this.parent._selectedBlocks];
+ switch (a) {
+ case 'duplicate':
+ this.parent._duplicateSelectedBlocks();
+ break;
+ case 'copy':
+ this.parent._copySelectedBlocks();
+ break;
+ case 'reps-inc':
+ ids.forEach((id) => this.parent._changeReps(id, +1));
+ break;
+ case 'reps-dec':
+ ids.forEach((id) => this.parent._changeReps(id, -1));
+ break;
+ case 'delete':
+ this.parent._deleteSelectedBlocks();
+ break;
+ }
+ });
+ // Dismiss on outside click / escape
+ this.parent._blockMenuEl = menu;
+ this.parent._blockMenuDismiss = (ev) => {
+ if (ev.type === 'keydown' && ev.key !== 'Escape') return;
+ if (ev.type === 'mousedown' && menu.contains(ev.target)) return;
+ this._closeBlockMenu();
+ };
+ setTimeout(() => {
+ document.addEventListener('mousedown', this.parent._blockMenuDismiss);
+ document.addEventListener('keydown', this.parent._blockMenuDismiss);
+ }, 0);
}
_closeBlockMenu() {
- if (!this.parent._blockMenuEl) return;
- document.removeEventListener('mousedown', this.parent._blockMenuDismiss);
- document.removeEventListener('keydown', this.parent._blockMenuDismiss);
- this.parent._blockMenuEl.remove();
- this.parent._blockMenuEl = null;
- this.parent._blockMenuDismiss = null;
+ if (!this.parent._blockMenuEl) return;
+ document.removeEventListener('mousedown', this.parent._blockMenuDismiss);
+ document.removeEventListener('keydown', this.parent._blockMenuDismiss);
+ this.parent._blockMenuEl.remove();
+ this.parent._blockMenuEl = null;
+ this.parent._blockMenuDismiss = null;
}
_onChange(e) {
- const id = e.target.id;
- if (id === 'lm-lib-filter') {
- this.parent.libraryFeature?.setFilter(e.target.value);
- } else if (id === 'lm-lib-sort') {
- this.parent.libraryFeature?.setSort(e.target.value);
- } else if (id === 'lm-pad-cols') {
- this.parent._setPadCols(parseInt(e.target.value));
- } else if (id === 'lm-pad-rows') {
- this.parent._setPadRows(parseInt(e.target.value));
- } else if (id === 'la-bars') {
- const v = LoopUtils.validate.arrBars(e.target.value, this.parent.arrangementBars);
- const changed = v !== this.parent.arrangementBars;
- this.parent.arrangementBars = v;
- e.target.value = v;
- if (changed) {
- this.parent._renderTimeline();
- this.parent._pushArrHistory();
- this.parent._scheduleAutoSave();
- }
+ const id = e.target.id;
+ if (id === 'lm-lib-filter') {
+ this.parent.libraryFeature?.setFilter(e.target.value);
+ } else if (id === 'lm-lib-sort') {
+ this.parent.libraryFeature?.setSort(e.target.value);
+ } else if (id === 'lm-pad-cols') {
+ this.parent._setPadCols(parseInt(e.target.value));
+ } else if (id === 'lm-pad-rows') {
+ this.parent._setPadRows(parseInt(e.target.value));
+ } else if (id === 'la-bars') {
+ const v = LoopUtils.validate.arrBars(e.target.value, this.parent.arrangementBars);
+ const changed = v !== this.parent.arrangementBars;
+ this.parent.arrangementBars = v;
+ e.target.value = v;
+ if (changed) {
+ this.parent._renderTimeline();
+ this.parent._pushArrHistory();
+ this.parent._scheduleAutoSave();
}
+ }
}
_onInput(e) {
- const id = e.target.id;
- if (id === 'lm-lib-search') {
- this.parent.libraryFeature?.setSearch(e.target.value);
- } else if (id === 'lm-live-search') {
- this.parent.liveFeature?.setSearch(e.target.value);
- } else if (id === 'la-palette-search') {
- this.parent._paletteSearch = e.target.value;
- this.parent._renderPalette();
- } else if (id === 'la-name-input') {
- if (this.parent.arrangementName === e.target.value) return;
- this.parent.arrangementName = e.target.value;
- this.parent._markArrDirty(true);
- this.parent._scheduleAutoSave();
- } else if (id === 'la-tempo') {
- const v = LoopUtils.validate.tempo(e.target.value, this.parent.arrangementTempo);
- if (v !== this.parent.arrangementTempo) {
- this.parent.arrangementTempo = v;
- this.parent._markArrDirty(true);
- this.parent._scheduleAutoSave();
- }
+ const id = e.target.id;
+ if (id === 'lm-lib-search') {
+ this.parent.libraryFeature?.setSearch(e.target.value);
+ } else if (id === 'lm-live-search') {
+ this.parent.liveFeature?.setSearch(e.target.value);
+ } else if (id === 'la-palette-search') {
+ this.parent._paletteSearch = e.target.value;
+ this.parent._renderPalette();
+ } else if (id === 'la-name-input') {
+ if (this.parent.arrangementName === e.target.value) return;
+ this.parent.arrangementName = e.target.value;
+ this.parent._markArrDirty(true);
+ this.parent._scheduleAutoSave();
+ } else if (id === 'la-tempo') {
+ const v = LoopUtils.validate.tempo(e.target.value, this.parent.arrangementTempo);
+ if (v !== this.parent.arrangementTempo) {
+ this.parent.arrangementTempo = v;
+ this.parent._markArrDirty(true);
+ this.parent._scheduleAutoSave();
}
+ }
}
- }
+ }
- if (typeof window !== 'undefined') {
- window.LoopCreatorModalEvents = LoopCreatorModalEvents;
- }
+ if (typeof window !== 'undefined') {
+ window.LoopCreatorModalEvents = LoopCreatorModalEvents;
+ }
})();
diff --git a/public/js/features/LoopCreatorModalView.js b/public/js/features/LoopCreatorModalView.js
index a397eebb1..de3819de6 100644
--- a/public/js/features/LoopCreatorModalView.js
+++ b/public/js/features/LoopCreatorModalView.js
@@ -18,28 +18,28 @@
// ============================================================================
(function () {
- 'use strict';
+ 'use strict';
- class LoopCreatorModalView {
- /** @param {LoopCreatorModal} parent */
- constructor(parent) {
- this.parent = parent;
- }
+ class LoopCreatorModalView {
+ /** @param {LoopCreatorModal} parent */
+ constructor(parent) {
+ this.parent = parent;
+ }
_renderHeader() {
- const showSave = this.parent.activeTab === 'arranger';
- return `
+ const showSave = this.parent.activeTab === 'arranger';
+ return `