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'), backAngle: document.getElementById('back_angle'), legAngle: document.getElementById('leg_angle'), headAngle: document.getElementById('head_angle'), 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'), motion: document.getElementById('recliner-motion'), motionState: document.getElementById('motion-state'), angleValue: document.getElementById('angle-value'), anglePill: document.getElementById('angle-pill'), backAnglePill: document.getElementById('back-angle-pill'), legAnglePill: document.getElementById('leg-angle-pill'), headAnglePill: document.getElementById('head-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; let activeActuator = 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 capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1); const currentState = () => ({ angle: Number(controls.angle?.value || 0), backAngle: Number(controls.backAngle?.value || 0), legAngle: Number(controls.legAngle?.value || 0), headAngle: Number(controls.headAngle?.value || 0), intensity: Number(controls.intensity?.value || 0), pattern: controls.pattern?.value || 'continuous' }); const vibrationIsRunning = () => continuousVibrationInterval !== null; const updateMotionIndicator = (isActive, pattern, intensity) => { if (!ui.motionState) { return; } if (!isActive) { ui.motionState.textContent = 'Preview idle'; return; } ui.motionState.textContent = `${capitalize(pattern)} haptics · ${intensity}%`; }; const syncFigureVariables = (state) => { if (!ui.figure) { return; } const driftX = -Math.round(state.angle * 0.22); const driftY = Math.round(state.angle * 0.08); const shakeX = (1.6 + state.intensity * 0.055).toFixed(2); const shakeY = (0.9 + state.intensity * 0.028).toFixed(2); const shakeRotate = (0.28 + state.intensity * 0.008).toFixed(2); const vibeSpeed = Math.max(90, 220 - state.intensity).toFixed(0); const glowStrength = Math.min(0.46, 0.14 + state.intensity / 320).toFixed(2); const glowOpacity = Math.min(0.92, 0.36 + state.intensity / 180).toFixed(2); ui.figure.style.setProperty('--angle', String(state.angle)); ui.figure.style.setProperty('--back-angle', String(state.backAngle)); ui.figure.style.setProperty('--leg-angle', String(state.legAngle)); ui.figure.style.setProperty('--head-angle', String(state.headAngle)); ui.figure.style.setProperty('--intensity', String(state.intensity)); ui.figure.style.setProperty('--drift-x', `${driftX}px`); ui.figure.style.setProperty('--drift-y', `${driftY}px`); ui.figure.style.setProperty('--shake-x', `${shakeX}px`); ui.figure.style.setProperty('--shake-y', `${shakeY}px`); ui.figure.style.setProperty('--shake-rotate', `${shakeRotate}deg`); ui.figure.style.setProperty('--vibe-speed', `${vibeSpeed}ms`); ui.figure.style.setProperty('--glow-strength', glowStrength); ui.figure.style.setProperty('--glow-opacity', glowOpacity); ui.figure.dataset.pattern = state.pattern; }; const setVisualVibrationState = (isActive, state = currentState()) => { syncFigureVariables(state); if (ui.figure) { ui.figure.classList.toggle('is-vibrating', isActive); } if (ui.motion) { ui.motion.classList.toggle('is-vibrating', isActive); ui.motion.classList.toggle('vibration-continuous', isActive && state.pattern === 'continuous'); ui.motion.classList.toggle('vibration-pulse', isActive && state.pattern === 'pulse'); } updateMotionIndicator(isActive, state.pattern, state.intensity); }; const updateVisualization = () => { const state = currentState(); const tone = presetTone(state.angle); syncFigureVariables(state); const map = { angleValue: `${state.angle}`, anglePill: `${state.angle}°`, backAnglePill: `${state.backAngle}°`, legAnglePill: `${state.legAngle}°`, headAnglePill: `${state.headAngle}°`, 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; } }); if (!vibrationIsRunning()) { updateMotionIndicator(false, state.pattern, state.intensity); } else { setVisualVibrationState(true, state); } }; const applyPresetState = (preset) => { if (!preset) return; if (controls.angle && preset.angle_deg !== undefined) controls.angle.value = preset.angle_deg; if (controls.backAngle && preset.back_angle !== undefined) controls.backAngle.value = preset.back_angle; if (controls.legAngle && preset.leg_angle !== undefined) controls.legAngle.value = preset.leg_angle; if (controls.headAngle && preset.head_angle !== undefined) controls.headAngle.value = preset.head_angle; 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 stopEffect = async (actuator) => { if (!actuator) { return; } try { if (typeof actuator.reset === 'function') { await actuator.reset(); return; } if (typeof actuator.playEffect === 'function') { await actuator.playEffect('dual-rumble', { startDelay: 0, duration: 0, weakMagnitude: 0, strongMagnitude: 0 }); return; } if (typeof actuator.pulse === 'function') { await actuator.pulse(0, 0); } } catch (error) { console.debug('Unable to reset vibration actuator cleanly.', error); } }; 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; } setVisualVibrationState(false); 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; } if (vibrationIsRunning() && !actuator) { window.clearInterval(continuousVibrationInterval); continuousVibrationInterval = null; activeActuator = null; if (ui.testButton) { ui.testButton.textContent = 'Test vibration (continuous)'; } setVisualVibrationState(false); } }; 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 stopVibration = async ({ silent = false } = {}) => { if (continuousVibrationInterval) { window.clearInterval(continuousVibrationInterval); continuousVibrationInterval = null; } await stopEffect(activeActuator); activeActuator = null; if (ui.testButton) { ui.testButton.textContent = 'Test vibration (continuous)'; } setVisualVibrationState(false); if (!silent) { notify('Vibration stopped.'); } }; const startVibration = async (actuator, { silent = false } = {}) => { const state = currentState(); const normalizedIntensity = Math.max(0, Math.min(1, state.intensity / 100)); activeActuator = actuator; if (ui.testButton) { ui.testButton.textContent = 'Stop vibration'; } setVisualVibrationState(true, state); if (state.pattern === 'pulse') { const pulse = async () => { const live = currentState(); setVisualVibrationState(true, live); await playEffect(actuator, Math.max(0, Math.min(1, live.intensity / 100)), 500); }; await pulse(); continuousVibrationInterval = window.setInterval(() => { pulse().catch((error) => console.error(error)); }, 800); } else { await playEffect(actuator, normalizedIntensity, 150); continuousVibrationInterval = window.setInterval(() => { const live = currentState(); setVisualVibrationState(true, live); playEffect(actuator, Math.max(0, Math.min(1, live.intensity / 100)), 150).catch((error) => console.error(error)); }, 100); } if (!silent) { notify(`Running ${state.pattern} vibration at ${state.intensity}%.`); } }; const restartVibration = async () => { if (!vibrationIsRunning()) { return; } const gamepad = selectedGamepad(); const actuator = getActuator(gamepad); if (!gamepad || !actuator) { await stopVibration({ silent: true }); return; } await stopVibration({ silent: true }); await startVibration(actuator, { silent: true }); }; const runVibration = async () => { const gamepad = selectedGamepad(); const actuator = getActuator(gamepad); if (!gamepad || !actuator) { notify('No compatible haptic controller is currently selected.'); refreshGamepads(); return; } if (vibrationIsRunning()) { await stopVibration(); return; } try { await startVibration(actuator); } catch (error) { console.error(error); await stopVibration({ silent: true }); notify('The browser detected the controller, but vibration could not start.'); } }; const wirePresetButtons = () => { document.querySelectorAll('.apply-preset').forEach((button) => { button.addEventListener('click', () => { applyPresetState({ name: button.dataset.name || '', angle_deg: button.dataset.angle || 0, back_angle: button.dataset.back || 0, leg_angle: button.dataset.leg || 0, head_angle: button.dataset.head || 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().catch((error) => console.error(error)); }); } if (ui.resetButton) { ui.resetButton.addEventListener('click', () => { applyPresetState(appConfig.defaults || {}); notify('Demo defaults restored.'); }); } [controls.angle, controls.backAngle, controls.legAngle, controls.headAngle, controls.intensity].forEach((element) => { if (element) { element.addEventListener('input', updateVisualization); element.addEventListener('change', updateVisualization); } }); if (controls.pattern) { controls.pattern.addEventListener('input', () => { updateVisualization(); restartVibration().catch((error) => console.error(error)); }); controls.pattern.addEventListener('change', () => { updateVisualization(); restartVibration().catch((error) => console.error(error)); }); } 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}`); }); // Keyboard Shortcuts document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; const step = e.shiftKey ? 10 : 5; switch (e.key.toLowerCase()) { case 'v': runVibration().catch(console.error); break; case 'r': if (ui.resetButton) ui.resetButton.click(); break; case 'arrowup': if (controls.angle) { controls.angle.value = Math.min(180, Number(controls.angle.value) + step); updateVisualization(); } break; case 'arrowdown': if (controls.angle) { controls.angle.value = Math.max(0, Number(controls.angle.value) - step); updateVisualization(); } break; case 'arrowright': if (controls.intensity) { controls.intensity.value = Math.min(100, Number(controls.intensity.value) + step); updateVisualization(); } break; case 'arrowleft': if (controls.intensity) { controls.intensity.value = Math.max(0, Number(controls.intensity.value) - step); updateVisualization(); } break; } }); 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); } stopEffect(activeActuator).catch(() => {}); }); });