document.addEventListener('DOMContentLoaded', () => { const appConfig = window.appConfig || {}; const toastElement = document.getElementById('app-toast'); const toastMessage = document.getElementById('toast-message'); const toast = toastElement && window.bootstrap ? new window.bootstrap.Toast(toastElement, { delay: 3200 }) : null; const controls = { angle: document.getElementById('angle_deg'), intensity: document.getElementById('intensity_pct'), pattern: document.getElementById('pattern_mode'), name: document.getElementById('name'), notes: document.getElementById('notes') }; const ui = { figure: document.getElementById('recliner-figure'), angleValue: document.getElementById('angle-value'), anglePill: document.getElementById('angle-pill'), intensityPill: document.getElementById('intensity-pill'), statAngle: document.getElementById('stat-angle'), statIntensity: document.getElementById('stat-intensity'), statPattern: document.getElementById('stat-pattern'), saveAngle: document.getElementById('save-angle'), saveIntensity: document.getElementById('save-intensity'), savePattern: document.getElementById('save-pattern'), summaryAngle: document.getElementById('summary-angle'), summaryIntensity: document.getElementById('summary-intensity'), summaryPattern: document.getElementById('summary-pattern'), reclineMode: document.getElementById('recline-mode'), gamepadState: document.getElementById('gamepad-state'), gamepadSelect: document.getElementById('gamepad-select'), testButton: document.getElementById('test-vibration'), resetButton: document.getElementById('reset-defaults') }; let knownGamepads = []; let scanTimer = null; let continuousVibrationInterval = null; const notify = (message) => { if (!message) { return; } if (toastMessage) { toastMessage.textContent = message; } if (toast) { toast.show(); } }; const presetTone = (angle) => { if (angle >= 135) return 'Deep recline'; if (angle >= 90) return 'Lounge'; if (angle >= 45) return 'Reading'; return 'Upright'; }; const currentState = () => ({ angle: Number(controls.angle?.value || 0), intensity: Number(controls.intensity?.value || 0), pattern: controls.pattern?.value || 'continuous' }); const updateVisualization = () => { const state = currentState(); const tone = presetTone(state.angle); if (ui.figure) { ui.figure.style.setProperty('--angle', String(state.angle)); ui.figure.style.setProperty('--intensity', String(state.intensity)); } const map = { angleValue: `${state.angle}`, anglePill: `${state.angle}°`, intensityPill: `${state.intensity}%`, statAngle: `${state.angle}°`, statIntensity: `${state.intensity}%`, statPattern: capitalize(state.pattern), saveAngle: `${state.angle}°`, saveIntensity: `${state.intensity}%`, savePattern: capitalize(state.pattern), summaryAngle: `${state.angle}°`, summaryIntensity: `${state.intensity}%`, summaryPattern: capitalize(state.pattern), reclineMode: tone }; Object.entries(map).forEach(([key, value]) => { if (ui[key]) { ui[key].textContent = value; } }); }; const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1); const applyPresetState = (preset) => { if (!preset) return; if (controls.angle && preset.angle_deg !== undefined) controls.angle.value = preset.angle_deg; if (controls.intensity && preset.intensity_pct !== undefined) controls.intensity.value = preset.intensity_pct; if (controls.pattern && preset.pattern_mode) controls.pattern.value = preset.pattern_mode; if (controls.name && preset.name !== undefined) controls.name.value = preset.name; if (controls.notes && preset.notes !== undefined) controls.notes.value = preset.notes; updateVisualization(); }; const getActuator = (gamepad) => { if (!gamepad) return null; if (gamepad.vibrationActuator) return gamepad.vibrationActuator; if (Array.isArray(gamepad.hapticActuators) && gamepad.hapticActuators.length > 0) { return gamepad.hapticActuators[0]; } return null; }; const selectedGamepad = () => { if (!ui.gamepadSelect || !knownGamepads.length) return null; const selectedIndex = Number(ui.gamepadSelect.value || knownGamepads[0].index); return knownGamepads.find((gamepad) => gamepad.index === selectedIndex) || knownGamepads[0]; }; const refreshGamepads = () => { if (!navigator.getGamepads) { if (ui.gamepadState) { ui.gamepadState.textContent = 'Gamepad API unavailable'; } if (ui.gamepadSelect) { ui.gamepadSelect.innerHTML = ''; ui.gamepadSelect.disabled = true; } if (ui.testButton) { ui.testButton.disabled = true; } return; } knownGamepads = Array.from(navigator.getGamepads()).filter(Boolean); const previousValue = ui.gamepadSelect ? ui.gamepadSelect.value : ''; if (ui.gamepadSelect) { ui.gamepadSelect.innerHTML = ''; if (!knownGamepads.length) { const option = document.createElement('option'); option.textContent = 'No controller detected'; option.value = ''; ui.gamepadSelect.appendChild(option); ui.gamepadSelect.disabled = true; } else { knownGamepads.forEach((gamepad) => { const option = document.createElement('option'); const supported = getActuator(gamepad) ? 'haptics ready' : 'no haptics'; option.value = String(gamepad.index); option.textContent = `${gamepad.id} (${supported})`; ui.gamepadSelect.appendChild(option); }); ui.gamepadSelect.disabled = false; const hasPrevious = knownGamepads.some((gamepad) => String(gamepad.index) === previousValue); ui.gamepadSelect.value = hasPrevious ? previousValue : String(knownGamepads[0].index); } } const active = selectedGamepad(); const actuator = getActuator(active); if (ui.gamepadState) { if (!active) { ui.gamepadState.textContent = 'Connect a controller to enable rumble'; } else if (!actuator) { ui.gamepadState.textContent = 'Controller found, but haptics are unavailable'; } else { ui.gamepadState.textContent = 'Controller connected and ready'; } } if (ui.testButton) { ui.testButton.disabled = !active || !actuator; } }; const playEffect = async (actuator, intensity, duration) => { if (typeof actuator.playEffect === 'function') { await actuator.playEffect('dual-rumble', { startDelay: 0, duration, weakMagnitude: Math.min(1, intensity * 0.8 + 0.1), strongMagnitude: intensity }); return; } if (typeof actuator.pulse === 'function') { await actuator.pulse(intensity, duration); } }; const runVibration = async () => { const gamepad = selectedGamepad(); const actuator = getActuator(gamepad); if (!gamepad || !actuator) { notify('No compatible haptic controller is currently selected.'); refreshGamepads(); return; } const { intensity, pattern } = currentState(); const normalizedIntensity = Math.max(0, Math.min(1, intensity / 100)); if (continuousVibrationInterval) { window.clearInterval(continuousVibrationInterval); continuousVibrationInterval = null; ui.testButton.textContent = 'Test vibration (continuous)'; notify('Vibration stopped.'); return; } ui.testButton.textContent = 'Stop vibration'; try { if (pattern === 'pulse') { const pulse = async () => { await playEffect(actuator, normalizedIntensity, 500); }; pulse(); continuousVibrationInterval = window.setInterval(pulse, 800); } else { // For continuous, we re-trigger every 100ms await playEffect(actuator, normalizedIntensity, 150); continuousVibrationInterval = window.setInterval(async () => { await playEffect(actuator, normalizedIntensity, 150); }, 100); } notify(`Running ${pattern} vibration at ${intensity}%.`); } catch (error) { console.error(error); notify('The browser detected the controller, but vibration could not start.'); ui.testButton.textContent = 'Test vibration (continuous)'; } }; const wirePresetButtons = () => { document.querySelectorAll('.apply-preset').forEach((button) => { button.addEventListener('click', () => { applyPresetState({ name: button.dataset.name || '', angle_deg: button.dataset.angle || 0, intensity_pct: button.dataset.intensity || 0, pattern_mode: button.dataset.pattern || 'continuous', notes: button.dataset.notes || '' }); window.location.hash = 'simulator'; notify(`Loaded preset “${button.dataset.name || 'Preset'}” into the simulator.`); }); }); }; if (ui.testButton) { ui.testButton.addEventListener('click', runVibration); } if (ui.resetButton) { ui.resetButton.addEventListener('click', () => { applyPresetState(appConfig.defaults || {}); notify('Demo defaults restored.'); }); } [controls.angle, controls.intensity, controls.pattern].forEach((element) => { if (element) { element.addEventListener('input', updateVisualization); element.addEventListener('change', updateVisualization); } }); if (ui.gamepadSelect) { ui.gamepadSelect.addEventListener('change', refreshGamepads); } window.addEventListener('gamepadconnected', (event) => { refreshGamepads(); notify(`Connected: ${event.gamepad.id}`); }); window.addEventListener('gamepaddisconnected', (event) => { refreshGamepads(); notify(`Disconnected: ${event.gamepad.id}`); }); applyPresetState(appConfig.initialPreset || {}); wirePresetButtons(); refreshGamepads(); updateVisualization(); if (appConfig.toastMessage) { notify(appConfig.toastMessage); } if (navigator.getGamepads) { scanTimer = window.setInterval(refreshGamepads, 1500); } window.addEventListener('beforeunload', () => { if (scanTimer) { window.clearInterval(scanTimer); } if (continuousVibrationInterval) { window.clearInterval(continuousVibrationInterval); } }); });