311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
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'),
|
|
duration: document.getElementById('duration_ms'),
|
|
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'),
|
|
durationPill: document.getElementById('duration-pill'),
|
|
statAngle: document.getElementById('stat-angle'),
|
|
statIntensity: document.getElementById('stat-intensity'),
|
|
statPattern: document.getElementById('stat-pattern'),
|
|
statDuration: document.getElementById('stat-duration'),
|
|
saveAngle: document.getElementById('save-angle'),
|
|
saveIntensity: document.getElementById('save-intensity'),
|
|
savePattern: document.getElementById('save-pattern'),
|
|
saveDuration: document.getElementById('save-duration'),
|
|
summaryAngle: document.getElementById('summary-angle'),
|
|
summaryIntensity: document.getElementById('summary-intensity'),
|
|
summaryPattern: document.getElementById('summary-pattern'),
|
|
summaryTone: document.getElementById('summary-tone'),
|
|
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;
|
|
|
|
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',
|
|
duration: Number(controls.duration?.value || 1000)
|
|
});
|
|
|
|
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}%`,
|
|
durationPill: `${state.duration} ms`,
|
|
statAngle: `${state.angle}°`,
|
|
statIntensity: `${state.intensity}%`,
|
|
statPattern: capitalize(state.pattern),
|
|
statDuration: `${state.duration} ms`,
|
|
saveAngle: `${state.angle}°`,
|
|
saveIntensity: `${state.intensity}%`,
|
|
savePattern: capitalize(state.pattern),
|
|
saveDuration: `${state.duration} ms`,
|
|
summaryAngle: `${state.angle}°`,
|
|
summaryIntensity: `${state.intensity}%`,
|
|
summaryPattern: capitalize(state.pattern),
|
|
summaryTone: tone,
|
|
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.duration && preset.duration_ms !== undefined) controls.duration.value = preset.duration_ms;
|
|
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 = '<option>Unsupported browser</option>';
|
|
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, duration, pattern } = currentState();
|
|
const normalizedIntensity = Math.max(0, Math.min(1, intensity / 100));
|
|
ui.testButton.disabled = true;
|
|
ui.testButton.textContent = 'Testing…';
|
|
|
|
try {
|
|
if (pattern === 'pulse') {
|
|
let remaining = duration;
|
|
while (remaining > 0) {
|
|
const burst = Math.min(160, remaining);
|
|
await playEffect(actuator, normalizedIntensity, burst);
|
|
remaining -= burst;
|
|
if (remaining > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
|
}
|
|
}
|
|
} else {
|
|
await playEffect(actuator, normalizedIntensity, duration);
|
|
}
|
|
notify(`Running ${pattern} vibration at ${intensity}% for ${duration} ms.`);
|
|
} catch (error) {
|
|
console.error(error);
|
|
notify('The browser detected the controller, but vibration could not start.');
|
|
} finally {
|
|
ui.testButton.textContent = 'Test vibration';
|
|
refreshGamepads();
|
|
}
|
|
};
|
|
|
|
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',
|
|
duration_ms: button.dataset.duration || 1000,
|
|
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, controls.duration].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);
|
|
}
|
|
});
|
|
});
|