Files
ytplayer/frontend/midi.js
2026-10-03 14:56:06 +08:00

38 lines
5.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* MIDI pedal learning. No MIDI access until explicitly enabled. */
(function(root) {
'use strict';
const actions = { toggle: 'Play / Pause', next: 'Next in setlist', repeat: 'Repeat current section', loop: 'Set / toggle A–B loop', panic: 'Panic — fade to silence' };
function decode(data) { if (!data || data.length < 3) return null; const type = data[0] & 240, channel = data[0] & 15; if (![128,144,176].includes(type)) return null; return { key: `${type === 176 ? 'cc' : 'note'}:${channel}:${data[1]}`, down: type === 176 ? data[2] >= 64 : type === 144 && data[2] > 0 }; }
function createGate() { const held = new Set(); return { accept(port, message) { const key = `${port}:${message.key}`; const was = held.has(key); if (message.down) held.add(key); else held.delete(key); return message.down && !was; }, reset() { held.clear(); } }; }
let adapter, access, learning = '', status = '', panelRefresh = () => {}, panicTimer;
const gate = createGate();
function receive(event) {
if (!adapter.settings().midiEnabled) return;
const message = decode(event.data); if (!message || !gate.accept(event.target?.id || '', message)) return;
const settings = adapter.settings();
if (learning) { const mappings = { ...(settings.midiMappings || {}) }; for (const [action, key] of Object.entries(mappings)) if (key === message.key) delete mappings[action]; mappings[learning] = message.key; settings.midiMappings = mappings; learning = ''; adapter.persist(); status = 'Mapping saved'; panelRefresh(); return; }
const action = Object.keys(actions).find(action => settings.midiMappings?.[action] === message.key); if (action) adapter.action(action);
}
function attach() { gate.reset(); for (const input of access.inputs.values()) input.onmidimessage = input.state === 'disconnected' ? null : receive; status = `${[...access.inputs.values()].filter(p => p.state !== 'disconnected').length} MIDI input(s) connected`; panelRefresh(); }
async function enable(on) {
adapter.settings().midiEnabled = on; adapter.persist(); learning = ''; gate.reset();
if (!on) { if (access) for (const input of access.inputs.values()) input.onmidimessage = null; status = 'MIDI is off'; panelRefresh(); return; }
try { access ||= await root.navigator.requestMIDIAccess({ sysex: false }); access.onstatechange = attach; attach(); }
catch (error) { adapter.settings().midiEnabled = false; adapter.persist(); status = `MIDI access unavailable: ${error.message}`; panelRefresh(); }
}
function fade({ volume, setVolume, pause, restore }) { clearInterval(panicTimer); const start = Date.now(); panicTimer = setInterval(() => { const fraction = Math.max(0, 1 - (Date.now() - start) / 1000); setVolume(volume * fraction); if (!fraction) { clearInterval(panicTimer); pause(); restore(); } }, 25); }
function configure(options) {
adapter = options;
root.SettingsSections.register({ id: 'midi', title: 'MIDI pedalboard', cluster: 'Live & worship', summary: () => adapter.settings().midiEnabled ? 'On · pedal mappings' : 'Off', render(container) {
const doc = container.ownerDocument;
if (!root.navigator.requestMIDIAccess) { const p = doc.createElement('p'); p.textContent = 'Web MIDI is unavailable in this browser. iPhone and iPad browsers do not support Web MIDI.'; container.append(p); return; }
const label = doc.createElement('label'); label.className = 'set-row'; label.textContent = 'Enable MIDI pedalboard'; const toggle = doc.createElement('input'); toggle.type = 'checkbox'; toggle.checked = !!adapter.settings().midiEnabled; toggle.onchange = () => enable(toggle.checked); label.append(toggle);
const info = doc.createElement('p'); info.setAttribute('role', 'status'); const rows = doc.createElement('div'); container.append(label, info, rows);
const paint = () => { toggle.checked = !!adapter.settings().midiEnabled; info.textContent = learning ? `Press the pedal for ${actions[learning]}` : status || 'Enable MIDI to connect a pedalboard.'; rows.replaceChildren(); for (const [action, title] of Object.entries(actions)) { const row = doc.createElement('div'); row.className = 'download-job'; const text = doc.createElement('b'); text.textContent = `${title} · ${adapter.settings().midiMappings?.[action] || 'Unassigned'}`; const buttons = doc.createElement('div'); for (const name of ['Learn', 'Clear']) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = name; b.disabled = name === 'Learn' && !adapter.settings().midiEnabled; b.setAttribute('aria-label', `${name} MIDI ${title}`); b.onclick = () => { if (name === 'Learn') { learning = learning === action ? '' : action; gate.reset(); } else { delete adapter.settings().midiMappings?.[action]; adapter.persist(); } paint(); }; buttons.append(b); } row.append(text, buttons); rows.append(row); } };
panelRefresh = paint; paint(); return () => { if (panelRefresh === paint) panelRefresh = () => {}; learning = ''; };
} });
if (adapter.settings().midiEnabled && root.navigator.requestMIDIAccess) enable(true);
}
const api = { configure, decode, createGate, fade }; if (typeof module !== 'undefined') module.exports = api; else root.MidiPedals = api;
})(typeof window !== 'undefined' ? window : globalThis);